feat(rdp): 新增 RDP 远程桌面/SMB/账单同步能力 + 清理编译产物

主要变更:
- RDP 远程桌面:新增 backend/pkg/rdp(RDP 引擎 + credssp/display/rle/trace)、
  backend/controller/rdp*(连接/凭证/文件/会话/实时)、frontend RDP 组件与 API
- Windows SMB 文件传输:backend/pkg/winsmb
- 云账单同步:backend/pkg/billingsync + billing_sync/aliyun_billing,前端账单详情
- Agent CLI Kubernetes 工具:kubernetes_action/get、pod_execute/logs
- 服务器导入助手:server_import_helpers、ImportCredentialSettings
- 国际化与组件重构(cloud/deploy/monitor/server/system 等多模块)
- 清理编译产物二进制:移除 backend/server、backend/agent/bin/aiops-agent-*
- .gitignore:新增 backend/agent/ 与 .cache/(避免 334MB go-build 缓存误入库)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
LoganandClaude committed 2026-07-04 01:50:36 +08:00
1 parent 777d9e4e71
commit 76ec9387f0
202 files changed
+16555 -2543

No files matched your search

@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
@@ -20,39 +20,37 @@ import {
ChevronRight,
ChevronLeft,
Server,
RefreshCw,
Check,
MapPin,
Search,
AlertCircle,
RefreshCw, Check, MapPin, Search, AlertCircle,
} from 'lucide-react';
import { cloudApi } from '@/api';
import i18n from '@/i18n';
import { cloudApi, credentialApi } from '@/api';
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
import { ImportCredentialSettings } from './ImportCredentialSettings';
import type { Credential } from '@/components/deploy/credential-manager/types';
interface CloudServerImportProps {
open: boolean;
onClose: () => void;
/** 预选云账号,跳过厂商/账号选择步骤 // Logan.Li */
initialAccountId?: number | string;
}
const cloudProviders = [
{ id: 'aws', name: 'AWS', color: 'bg-orange-500', icon: 'AWS' },
{ id: 'aliyun', name: i18n.t('cloud.aliyun'), color: 'bg-orange-600', icon: 'AL' },
{ id: 'tencent', name: i18n.t('cloud.tencent'), color: 'bg-blue-500', icon: 'TX' },
{ id: 'huawei', name: i18n.t('cloud.huawei'), color: 'bg-red-500', icon: 'HW' },
{ id: 'azure', name: 'Azure', color: 'bg-blue-600', icon: 'AZ' },
{ id: 'aws', name: 'AWS' },
{ id: 'aliyun', name: '阿里云' },
{ id: 'tencent', name: '腾讯云' },
{ id: 'huawei', name: '华为云' },
{ id: 'azure', name: 'Azure' },
];
export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
export function CloudServerImport({ open, onClose, initialAccountId }: CloudServerImportProps) {
const [cloudAccounts, setCloudAccounts] = useState<any[]>([]);
const [step, setStep] = useState(1);
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [selectedAccount, setSelectedAccount] = useState<any>(null);
// 区域相关状态
const [regions, setRegions] = useState<any[]>([]);
const [selectedRegions, setSelectedRegions] = useState<string[]>([]);
const [regionSearch, setRegionSearch] = useState('');
const [isSyncingRegions, setIsSyncingRegions] = useState(false);
// 主机相关状态
const [cloudServers, setCloudServers] = useState<any[]>([]);
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
@@ -60,28 +58,53 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState<{ imported: number; skipped: number } | null>(null);
const [defaultSshPort, setDefaultSshPort] = useState('22');
const [credentials, setCredentials] = useState<any[]>([]);
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('');
const [sshCredentials, setSshCredentials] = useState<Credential[]>([]);
const [rdpCredentials, setRdpCredentials] = useState<Credential[]>([]);
const [selectedSshCredentialId, setSelectedSshCredentialId] = useState<string>('');
const [selectedRdpCredentialId, setSelectedRdpCredentialId] = useState<string>('');
const preselectedRef = useRef<string | null>(null);
const loadAccounts = async () => {
try {
const res: Record<string, any> = await cloudApi.getAccounts();
setCloudAccounts(res?.list || []);
} catch (e) {
console.error(i18n.t('server.cloudserverimpo.sddeee730'), e);
console.error('加载云账号失败:', e);
}
};
useEffect(() => {
if (open) {
loadAccounts();
import('@/api').then(({ credentialApi }) => {
credentialApi.list().then(( res: any) => setCredentials(res?.list || []))
.catch(() => setCredentials([]));
if (!open) return;
loadAccounts();
Promise.all([
credentialApi.list({ secret_type: 'ssh', page_size: 100 }),
credentialApi.list({ secret_type: 'rdp', page_size: 100 }),
])
.then(([sshResp, rdpResp]) => {
setSshCredentials((sshResp?.list ?? []) as Credential[]);
setRdpCredentials((rdpResp?.list ?? []) as Credential[]);
})
.catch(() => {
setSshCredentials([]);
setRdpCredentials([]);
});
}
}, [open]);
// 导入凭证设置:Linux 默认 SSH,Windows 默认 RDP // Logan.Li
const importSettingsPanel = (step >= 3 && !importResult) ? (
<ImportCredentialSettings
compact
defaultSshPort={defaultSshPort}
onDefaultSshPortChange={setDefaultSshPort}
sshCredentials={sshCredentials}
rdpCredentials={rdpCredentials}
selectedSshCredentialId={selectedSshCredentialId}
selectedRdpCredentialId={selectedRdpCredentialId}
onSelectedSshCredentialIdChange={setSelectedSshCredentialId}
onSelectedRdpCredentialIdChange={setSelectedRdpCredentialId}
/>
) : null;
const filteredAccounts = cloudAccounts.filter(
acc => acc.provider === selectedProvider && acc.status === 'active'
);
@@ -103,7 +126,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
const list = await loadRegions(accountId);
setRegions(list);
} catch (e) {
console.error(i18n.t('server.cloudserverimpo.s1d65993d'), e);
console.error('同步区域失败:', e);
throw e;
} finally {
setIsSyncingRegions(false);
@@ -126,6 +149,39 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
}
}, [selectedAccount, loadRegions, syncRegions]);
// 预选账号时直接进入区域选择步骤 // Logan.Li
useEffect(() => {
if (!open) {
preselectedRef.current = null;
return;
}
if (!initialAccountId || cloudAccounts.length === 0) return;
if (preselectedRef.current === String(initialAccountId)) return;
const account = cloudAccounts.find((a) => String(a.id) === String(initialAccountId));
if (!account) return;
preselectedRef.current = String(initialAccountId);
setSelectedProvider(account.provider);
setSelectedAccount(account);
setStep(3);
setRegions([]);
setSelectedRegions([]);
setCloudServers([]);
setSelectedServers([]);
setDiscoverErrors([]);
setImportResult(null);
(async () => {
const local = await loadRegions(account.id);
setRegions(local);
if (local.length === 0) {
try {
await syncRegions(account.id);
} catch {
// 同步失败不阻塞,用户可手动重试
}
}
})();
}, [open, initialAccountId, cloudAccounts, loadRegions, syncRegions]);
// ==================== 主机发现逻辑 ====================
const discoverInstances = useCallback(async () => {
if (!selectedAccount || selectedRegions.length === 0) return;
@@ -138,8 +194,8 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
setCloudServers(res?.list || []);
setDiscoverErrors(res?.errors || []);
} catch (e) {
console.error(i18n.t('server.cloudserverimpo.s5e749a34'), e);
setDiscoverErrors([e?.message || i18n.t('server.cloudserverimpo.s7d43dcbe')]);
console.error('发现主机失败:', e);
setDiscoverErrors([e?.message || '发现主机失败']);
} finally {
setIsDiscovering(false);
}
@@ -153,11 +209,12 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
const instancesToImport = cloudServers.filter(s => selectedServers.includes(s.instance_id));
const res: Record<string, any> = await cloudApi.importInstances(selectedAccount.id, instancesToImport, {
default_port: Number(defaultSshPort) || 22,
credential_id: selectedCredentialId ? Number(selectedCredentialId) : undefined,
ssh_credential_id: selectedSshCredentialId ? Number(selectedSshCredentialId) : undefined,
rdp_credential_id: selectedRdpCredentialId ? Number(selectedRdpCredentialId) : undefined,
});
setImportResult((res as any) || { imported: 0, skipped: 0 });
} catch (e) {
console.error(i18n.t('server.cloudserverimpo.s1b122554'), e);
console.error('导入失败:', e);
} finally {
setIsImporting(false);
}
@@ -214,7 +271,8 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
setDiscoverErrors([]);
setImportResult(null);
setDefaultSshPort('22');
setSelectedCredentialId('');
setSelectedSshCredentialId('');
setSelectedRdpCredentialId('');
};
const filteredRegions = regions.filter(r =>
@@ -224,18 +282,18 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
);
const steps = [
{ id: 1, title: i18n.t('server.cloudserverimpo.s7ef31abb'), icon: Cloud },
{ id: 2, title: i18n.t('server.cloudserverimpo.s02badc25'), icon: Key },
{ id: 3, title: i18n.t('server.cloudserverimpo.s97d03d46'), icon: MapPin },
{ id: 4, title: i18n.t('server.cloudserverimpo.s81eea51a'), icon: CheckCircle },
{ id: 1, title: '云厂商', icon: Cloud },
{ id: 2, title: '云账号', icon: Key },
{ id: 3, title: '选择区域', icon: MapPin },
{ id: 4, title: '导入确认', icon: CheckCircle },
];
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DialogHeader>
<DialogTitle className="text-xl"> {i18n.t('server.cloudserverimpo.sa558f8a8')} </DialogTitle>
<DialogDescription className="sr-only"> {i18n.t('server.cloudserverimpo.s86968159')} </DialogDescription>
<DialogTitle className="text-xl">导入云主机</DialogTitle>
<DialogDescription>从云账号批量导入主机资产,可选关联 Linux SSH 和 Windows RDP 凭证</DialogDescription>
</DialogHeader>
{/* Step Indicator */}
@@ -263,7 +321,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
{/* Step 1: Select Cloud Provider */}
{step === 1 && (
<div className="space-y-4">
<p className="text-[var(--dg-text-secondary)] text-sm"> {i18n.t('server.cloudserverimpo.s7c366475')} </p>
<p className="text-[var(--dg-text-secondary)] text-sm">请选择要导入云主机的云厂商</p>
<div className="grid grid-cols-3 gap-4">
{cloudProviders.map((provider) => (
<Card
@@ -276,8 +334,11 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
onClick={() => setSelectedProvider(provider.id)}
>
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${provider.color} flex items-center justify-center text-white font-bold text-sm`}>
{provider.icon}
<div
className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center shrink-0"
style={{ borderBottom: `2px solid ${getProviderColor(provider.id)}` }}
>
<CloudBrandIcon provider={provider.id} className="h-6 w-6" />
</div>
<span className="font-medium text-[var(--dg-text-primary)]">{provider.name}</span>
</CardContent>
@@ -290,7 +351,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
{/* Step 2: Select Account */}
{step === 2 && (
<div className="space-y-4">
<p className="text-[var(--dg-text-secondary)] text-sm"> {i18n.t('server.cloudserverimpo.se1a7870b')} </p>
<p className="text-[var(--dg-text-secondary)] text-sm">请选择云账号(AccessKey)</p>
{filteredAccounts.length > 0 ? (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{filteredAccounts.map((account) => (
@@ -305,7 +366,12 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
>
<CardContent className="p-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<Key className="h-5 w-5 dg-text-blue" />
<div
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)] flex items-center justify-center shrink-0"
style={{ borderBottom: `2px solid ${getProviderColor(account.provider)}` }}
>
<CloudBrandIcon provider={account.provider} className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-[var(--dg-text-primary)]">{account.name}</p>
<p className="text-xs text-[var(--dg-text-muted)]">
@@ -326,7 +392,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
) : (
<div className="text-center py-8 text-[var(--dg-text-muted)]">
<Key className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p> {i18n.t('server.cloudserverimpo.saa5f0df7')} </p>
<p>该云厂商暂无有效的云账号</p>
</div>
)}
</div>
@@ -350,19 +416,19 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
disabled={isSyncingRegions}
>
<RefreshCw className={`h-3 w-3 mr-1 ${isSyncingRegions ? 'animate-spin' : ''}`} />
{isSyncingRegions ? i18n.t('server.cloudserverimpo.sf787f452') : i18n.t('server.cloudserverimpo.saa44e4f6')}
{isSyncingRegions ? '同步中...' : '刷新区域'}
</Button>
</div>
{isSyncingRegions ? (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin dg-text-blue" />
<p className="text-[var(--dg-text-secondary)]"> {i18n.t('server.cloudserverimpo.s61df99e0')} </p>
<p className="text-[var(--dg-text-secondary)]">正在从云 API 同步区域列表...</p>
</div>
) : regions.length === 0 ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">
<MapPin className="h-10 w-10 mx-auto mb-3 opacity-50" />
<p> {i18n.t('cloud.noRegionData')} </p>
<p>暂无区域数据</p>
<Button
variant="outline"
size="sm"
@@ -380,14 +446,14 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
<div className="relative flex-1">
<Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)]" />
<Input
placeholder={i18n.t('cloud.searchRegion')}
placeholder="搜索区域名称或编码..."
value={regionSearch}
onChange={(e) => setRegionSearch(e.target.value)}
className="pl-9 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"
/>
</div>
<Button variant="outline" size="sm" onClick={handleSelectAllRegions} className="border-[var(--dg-border)]">
{selectedRegions.length === regions.length && regions.length > 0 ? i18n.t('approval.editor.deselectAll') : i18n.t('system.selectAll')}
{selectedRegions.length === regions.length && regions.length > 0 ? '取消全选' : '全选'}
</Button>
</div>
@@ -402,8 +468,8 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
onCheckedChange={handleSelectAllRegions}
/>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]"> {i18n.t('cloud.regionCode')} </th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]"> {i18n.t('cloud.regionName')} </th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域编码</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">区域名称</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">Endpoint</th>
</tr>
</thead>
@@ -433,6 +499,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
</div>
</>
)}
{importSettingsPanel}
</div>
)}
@@ -443,7 +510,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
/* 导入结果 */
<div className="text-center py-12">
<CheckCircle className="h-12 w-12 mx-auto mb-3 dg-text-green" />
<p className="text-lg font-medium text-[var(--dg-text-primary)]"> {i18n.t('common.importComplete')} </p>
<p className="text-lg font-medium text-[var(--dg-text-primary)]">导入完成</p>
<div className="flex items-center justify-center gap-6 mt-4">
<Badge className="bg-emerald-500/20 dg-text-green border-0 text-base px-4 py-1">
成功导入 {importResult.imported} 台
@@ -468,7 +535,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
{cloudServers.length > 0 && (
<>
<Button variant="outline" size="sm" onClick={handleSelectAllServers} className="border-[var(--dg-border)]">
{selectedServers.length === cloudServers.length && cloudServers.length > 0 ? i18n.t('approval.editor.deselectAll') : i18n.t('system.selectAll')}
{selectedServers.length === cloudServers.length && cloudServers.length > 0 ? '取消全选' : '全选'}
</Button>
<Badge className="bg-blue-500/20 dg-text-blue border-0">
已选 {selectedServers.length} 台
@@ -483,7 +550,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
className="border-[var(--dg-border)] dg-text-blue"
>
<RefreshCw className={`h-3 w-3 mr-1 ${isDiscovering ? 'animate-spin' : ''}`} />
{isDiscovering ? i18n.t('server.cloudserverimpo.s5bc3059d') : cloudServers.length > 0 ? i18n.t('server.cloudserverimpo.s569d92b0') : i18n.t('server.cloudserverimpo.sc0b92674')}
{isDiscovering ? '发现中...' : cloudServers.length > 0 ? '重新发现' : '发现主机'}
</Button>
</div>
</div>
@@ -506,7 +573,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 dg-text-yellow text-sm">
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium"> {i18n.t('server.cloudserverimpo.s6ce90825')} </p>
<p className="font-medium">部分区域发现失败:</p>
{discoverErrors.map((err, i) => (
<p key={i} className="text-xs mt-1 opacity-80">{err}</p>
))}
@@ -518,13 +585,13 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
{isDiscovering ? (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin dg-text-blue" />
<p className="text-[var(--dg-text-secondary)]"> {i18n.t('server.cloudserverimpo.s2401d988')} </p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1"> {i18n.t('server.cloudserverimpo.sbd41e8f6')} </p>
<p className="text-[var(--dg-text-secondary)]">正在发现云主机,请稍候...</p>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">正在逐区域查询,可能需要数秒</p>
</div>
) : cloudServers.length === 0 && !isDiscovering ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">
<Server className="h-10 w-10 mx-auto mb-3 opacity-50" />
<p> {i18n.t('server.cloudserverimpo.s85f8bc02')} </p>
<p>点击"发现主机"按钮,开始扫描所选区域</p>
</div>
) : (
<div className="border rounded-lg border-[var(--dg-border)] overflow-hidden max-h-[300px] overflow-y-auto">
@@ -537,7 +604,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
onCheckedChange={handleSelectAllServers}
/>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]"> {i18n.t('kubernetes.workload.hostname')} </th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
</tr>
</thead>
@@ -562,42 +629,7 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
</div>
)}
{/* SSH 端口 + 凭证关联 */}
{cloudServers.length > 0 && selectedServers.length > 0 && (
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">
SSH 端口
<span className="text-[var(--dg-text-muted)] ml-1">(导入时统一设置)</span>
</label>
<Input
type="number"
placeholder="22"
value={defaultSshPort}
onChange={(e) => setDefaultSshPort(e.target.value)}
className="w-32 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--dg-text-secondary)] mb-1">
关联凭证
<span className="text-[var(--dg-text-muted)] ml-1">(可选,用于SSH远程管理)</span>
</label>
<select
value={selectedCredentialId}
onChange={(e) => setSelectedCredentialId(e.target.value)}
className="w-full px-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-xs"
>
<option value=""> {i18n.t('server.cloudserverimpo.s45bf4d4b')} </option>
{credentials.map(( c: any) => (
<option key={c.id} value={String(c.id)}>
{c.type === 'ssh_key' ? '🔑' : '🔒'} {c.name}
</option>
))}
</select>
</div>
</div>
)}
{importSettingsPanel}
</>
)}
</div>
@@ -25,7 +25,8 @@ import {
} from 'lucide-react';
import { regionApi, serverApi, credentialApi } from '@/api';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
import { ImportCredentialSettings } from './ImportCredentialSettings';
import type { Credential } from '@/components/deploy/credential-manager/types';
interface RegionOption {
id: string;
@@ -39,7 +40,7 @@ interface ExcelServerImportProps {
}
type PreviewRow = {
row: number; hostname: string; ip: string; port: number;
row: number; hostname: string; ip: string; osType: string; protocol: string; port: number; rdpPort: number;
username: string; os: string; cpu: number; memory: number; disk: number; tags: string;
status: string; message: string;
};
@@ -91,13 +92,13 @@ function isValidIP(ip: string): boolean {
return /^(\d{1,3}\.){3}\d{1,3}$/.test(ip) && ip.split('.').every((o) => Number(o) >= 0 && Number(o) <= 255);
}
// 下载 CSV 导入模板 — 主机名+IP+端口(默认22),账号root,Agent 安装后自动补全 // Logan.Li
// 下载 CSV 导入模板,Windows 行可填写 os_type=windows 自动匹配 RDP 凭证 // Logan.Li
function downloadTemplate() {
const csv = [
'hostname,ip,port',
'web-01,192.168.1.10,22',
'db-01,192.168.1.20,22',
'cache-01,192.168.1.30,2222',
'hostname,ip,os_type,protocol,port,rdp_port,username',
'web-01,192.168.1.10,linux,ssh,22,,root',
'db-01,192.168.1.20,linux,ssh,22,,root',
'win-01,192.168.1.30,windows,rdp,,3389,Administrator',
].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
@@ -118,8 +119,10 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const [regions, setRegions] = useState<RegionOption[]>([]);
const [isImporting, setIsImporting] = useState(false);
// 凭据选择 // Logan.Li
const [credentials, setCredentials] = useState<JsonData[]>([]);
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('');
const [sshCredentials, setSshCredentials] = useState<Credential[]>([]);
const [rdpCredentials, setRdpCredentials] = useState<Credential[]>([]);
const [selectedSshCredentialId, setSelectedSshCredentialId] = useState<string>('');
const [selectedRdpCredentialId, setSelectedRdpCredentialId] = useState<string>('');
const [defaultSshPort, setDefaultSshPort] = useState('22');
// 对话框打开时拉取区域列表 + 凭据列表 // Logan.Li
@@ -128,9 +131,10 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
let cancelled = false;
(async () => {
try {
const [regionResp, credResp] = await Promise.all([
const [regionResp, sshCredResp, rdpCredResp] = await Promise.all([
regionApi.list(),
credentialApi.list(),
credentialApi.list({ secret_type: 'ssh', page_size: 100 }),
credentialApi.list({ secret_type: 'rdp', page_size: 100 }),
]);
if (cancelled) return;
const list = regionResp?.list ?? [];
@@ -139,11 +143,13 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
name: r.name ?? '',
code: r.code ?? '',
})));
setCredentials(credResp?.list ?? []);
setSshCredentials((sshCredResp?.list ?? []) as Credential[]);
setRdpCredentials((rdpCredResp?.list ?? []) as Credential[]);
} catch {
if (!cancelled) {
setRegions([]);
setCredentials([]);
setSshCredentials([]);
setRdpCredentials([]);
}
}
})();
@@ -157,7 +163,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const text = await selectedFile.text();
const rows = parseCSV(text);
if (rows.length === 0) {
toast.error(i18n.t('server.excelserverimpo.sdee9fcbf'));
toast.error('文件无有效数据,请使用模板格式(首行为表头)');
setIsUploading(false);
return;
}
@@ -165,26 +171,31 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const preview: PreviewRow[] = rows.map((r, idx) => {
const hostname = (r.hostname ?? '').trim();
const ip = (r.ip ?? '').trim();
const osType = (r.os_type ?? '').trim().toLowerCase() || ((r.os ?? '').toLowerCase().includes('windows') ? 'windows' : 'linux');
const protocol = (r.protocol ?? '').trim().toLowerCase() || (osType === 'windows' ? 'rdp' : 'ssh');
const row: PreviewRow = {
row: idx + 2,
hostname,
ip,
osType,
protocol,
port: Number(r.port) || 22,
username: (r.username ?? '').trim() || 'root',
rdpPort: Number(r.rdp_port) || 3389,
username: (r.username ?? '').trim() || (protocol === 'rdp' ? 'Administrator' : 'root'),
os: r.os ?? '',
cpu: Number(r.cpu) || 0,
memory: Number(r.memory) || 0,
disk: Number(r.disk) || 0,
tags: r.tags ?? '',
status: 'valid',
message: i18n.t('common.valid'),
message: '有效',
};
const errs: string[] = [];
if (!hostname) errs.push(i18n.t('server.excelserverimpo.sd274584b'));
if (!ip) errs.push(i18n.t('server.excelserverimpo.s7641b315'));
else if (!isValidIP(ip)) errs.push(i18n.t('server.excelserverimpo.s968c09ff'));
if (!hostname) errs.push('主机名缺失');
if (!ip) errs.push('IP缺失');
else if (!isValidIP(ip)) errs.push('IP格式错误');
const key = `${hostname}|${ip}`;
if (hostname && ip && seen.has(key)) errs.push(i18n.t('server.excelserverimpo.sa884370d'));
if (hostname && ip && seen.has(key)) errs.push('重复行');
else seen.add(key);
if (errs.length > 0) {
row.status = 'error';
@@ -195,7 +206,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
setPreviewData(preview);
setStep(step + 1);
} catch {
toast.error(i18n.t('server.excelserverimpo.s691054e3'));
toast.error('文件解析失败,请确认是 CSV 格式');
} finally {
setIsUploading(false);
}
@@ -220,7 +231,8 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
setStep(1);
setSelectedRegion('');
setDefaultSshPort('22');
setSelectedCredentialId('');
setSelectedSshCredentialId('');
setSelectedRdpCredentialId('');
setSelectedFile(null);
setPreviewData([]);
};
@@ -233,7 +245,10 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const servers = valid.map((d) => ({
hostname: d.hostname,
ip: d.ip,
os_type: d.osType,
protocol: d.protocol,
port: d.port,
rdp_port: d.rdpPort,
username: d.username,
os: d.os,
cpu: d.cpu,
@@ -244,7 +259,8 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const resp: Record<string, unknown> = await serverApi.batchCreate({
servers,
region_id: selectedRegion || undefined,
credential_id: selectedCredentialId ? Number(selectedCredentialId) : undefined,
ssh_credential_id: selectedSshCredentialId ? Number(selectedSshCredentialId) : undefined,
rdp_credential_id: selectedRdpCredentialId ? Number(selectedRdpCredentialId) : undefined,
default_port: Number(defaultSshPort) || 22,
});
const r = resp ?? {};
@@ -255,7 +271,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
resetState();
onClose();
} catch (e) {
toast.error(e?.message || i18n.t('server.excelserverimpo.se951a3c7'));
toast.error(e?.message || '导入失败,请重试');
} finally {
setIsImporting(false);
}
@@ -271,9 +287,9 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const errorCount = previewData.filter(d => d.status === 'error').length;
const steps = [
{ id: 1, title: i18n.t('deploy.detailDialog.config') },
{ id: 2, title: i18n.t('common.upload2') },
{ id: 3, title: i18n.t('monitor.alertEvents.acknowledge') },
{ id: 1, title: '配置' },
{ id: 2, title: '上传' },
{ id: 3, title: '确认' },
];
return (
@@ -281,7 +297,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<DialogContent className="max-w-3xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DialogHeader>
<DialogTitle className="text-xl">CSV 批量导入服务器</DialogTitle>
<DialogDescription className="sr-only"> {i18n.t('server.excelserverimpo.s58c25c4b')} </DialogDescription>
<DialogDescription className="sr-only">通过 CSV 文件批量导入服务器资产</DialogDescription>
</DialogHeader>
{/* Step Indicator */}
@@ -319,8 +335,8 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<FileSpreadsheet className="h-5 w-5 dg-text-green" />
</div>
<div>
<p className="font-medium text-[var(--dg-text-primary)]"> {i18n.t('server.excelserverimpo.s95e484bd')} </p>
<p className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('server.excelserverimpo.s21609324')} </p>
<p className="font-medium text-[var(--dg-text-primary)]">主机导入模板</p>
<p className="text-xs text-[var(--dg-text-muted)]">下载 CSV 模板,Linux 默认 SSH,Windows 默认 RDP</p>
</div>
</div>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={downloadTemplate}>
@@ -333,13 +349,13 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
{/* Region Selection */}
<div>
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2"> {i18n.t('server.cloudserverimpo.s97d03d46')} </label>
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">选择区域</label>
<select
value={selectedRegion}
onChange={(e) => setSelectedRegion(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
>
<option value=""> {i18n.t('server.excelserverimpo.sf2648916')} </option>
<option value="">请选择区域</option>
{regions.map((region) => (
<option key={region.id} value={region.id}>
{region.name} ({region.code})
@@ -348,46 +364,16 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
</select>
</div>
{/* 凭据选择(SSH密钥/密码) — 选中后自动关联到导入的每台服务器 // Logan.Li */}
<div>
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">
选择凭据
<span className="text-xs text-[var(--dg-text-muted)] ml-1">(可选,用于SSH连接)</span>
</label>
{credentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">
暂无凭据,先去「凭证配置」添加 SSH 密钥或密码
</p>
) : (
<select
value={selectedCredentialId}
onChange={(e) => setSelectedCredentialId(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
>
<option value=""> {i18n.t('server.excelserverimpo.s9d5df376')} </option>
{credentials.map((c: JsonData) => (
<option key={c.id} value={String(c.id)}>
{c.type === 'ssh_key' ? '🔑' : c.type === 'api_token' ? '🌐' : '🔒'} {c.name} ({c.type === 'ssh_key' ? i18n.t('server.excelserverimpo.s87941dca') : c.type === 'api_token' ? 'API Token' : i18n.t('system.passwordLabel')})
</option>
))}
</select>
)}
</div>
{/* SSH 端口配置 */}
<div>
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">
SSH 端口
<span className="text-xs text-[var(--dg-text-muted)] ml-1">(导入时统一设置,可在服务器管理页单独修改)</span>
</label>
<Input
type="number"
placeholder="22"
value={defaultSshPort}
onChange={(e) => setDefaultSshPort(e.target.value)}
className="w-32 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<ImportCredentialSettings
defaultSshPort={defaultSshPort}
onDefaultSshPortChange={setDefaultSshPort}
sshCredentials={sshCredentials}
rdpCredentials={rdpCredentials}
selectedSshCredentialId={selectedSshCredentialId}
selectedRdpCredentialId={selectedRdpCredentialId}
onSelectedSshCredentialIdChange={setSelectedSshCredentialId}
onSelectedRdpCredentialIdChange={setSelectedRdpCredentialId}
/>
</div>
)}
@@ -398,8 +384,8 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
className="border-2 border-dashed border-[var(--dg-border)] rounded-lg p-8 text-center hover:border-blue-500/50 transition-colors"
>
<Upload className="h-12 w-12 mx-auto mb-4 text-[var(--dg-text-muted)]" />
<p className="text-[var(--dg-text-secondary)] mb-2"> {i18n.t('server.excelserverimpo.sbab467bd')} </p>
<p className="text-xs text-[var(--dg-text-muted)] mb-4"> {i18n.t('server.excelserverimpo.s7bb06035')} </p>
<p className="text-[var(--dg-text-secondary)] mb-2">点击或拖拽文件到此处上传</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-4">支持 CSV 格式文件(可点击左侧"下载模板"获取)</p>
<Input
type="file"
accept=".csv,text/csv"
@@ -409,7 +395,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
/>
<label htmlFor="excel-upload">
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" asChild>
<span> {i18n.t('server.excelserverimpo.sfd7e0c99')} </span>
<span>选择文件</span>
</Button>
</label>
</div>
@@ -439,7 +425,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
{isUploading && (
<div className="text-center py-4">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<p className="text-[var(--dg-text-secondary)] text-sm"> {i18n.t('server.excelserverimpo.s8c0c7b6b')} </p>
<p className="text-[var(--dg-text-secondary)] text-sm">正在解析文件...</p>
</div>
)}
</div>
@@ -449,7 +435,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
{step === 3 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-[var(--dg-text-secondary)] text-sm"> {i18n.t('server.excelserverimpo.s3bc470c6')} </p>
<p className="text-[var(--dg-text-secondary)] text-sm">数据预览</p>
<div className="flex items-center gap-2">
<Badge className="bg-emerald-500/20 dg-text-green border-0">
<Check className="h-3 w-3 mr-1" />
@@ -474,10 +460,11 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<table className="w-full">
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]"> {i18n.t('monitor.logPanel.logResults.lineNo')} </th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]"> {i18n.t('kubernetes.workload.hostname')} </th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">行号</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]"> {i18n.t('system.terminalStatus')} </th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">类型</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">状态</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--dg-border)]">
@@ -486,6 +473,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<td className="px-3 py-2 text-sm text-[var(--dg-text-muted)]">{row.row}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{row.hostname || '-'}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.ip}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.osType === 'windows' ? 'Windows / RDP' : 'Linux / SSH'}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
{row.status === 'valid' && (
@@ -559,7 +547,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
) : (
<Check className="h-4 w-4 mr-1" />
)}
{isImporting ? i18n.t('system.importingDot') : `确认导入 (${validCount})`}
{isImporting ? '导入中...' : `确认导入 (${validCount})`}
</Button>
)}
</div>
@@ -0,0 +1,100 @@
import { Input } from '@/components/ui/input';
import type { Credential } from '@/components/deploy/credential-manager/types';
interface ImportCredentialSettingsProps {
defaultSshPort: string;
onDefaultSshPortChange: (value: string) => void;
sshCredentials: Credential[];
rdpCredentials: Credential[];
selectedSshCredentialId: string;
selectedRdpCredentialId: string;
onSelectedSshCredentialIdChange: (value: string) => void;
onSelectedRdpCredentialIdChange: (value: string) => void;
compact?: boolean;
}
function credentialLabel(cred: Credential) {
return `${cred.name}${cred.summary ? ` · ${cred.summary}` : ''}`;
}
export function ImportCredentialSettings({
defaultSshPort,
onDefaultSshPortChange,
sshCredentials,
rdpCredentials,
selectedSshCredentialId,
selectedRdpCredentialId,
onSelectedSshCredentialIdChange,
onSelectedRdpCredentialIdChange,
compact = false,
}: ImportCredentialSettingsProps) {
const labelClass = compact
? 'block text-xs font-medium text-[var(--dg-text-secondary)] mb-1'
: 'block text-sm font-medium text-[var(--dg-text-secondary)] mb-2';
const panelClass = compact
? 'grid grid-cols-1 sm:grid-cols-3 gap-4 p-4 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]'
: 'grid grid-cols-1 md:grid-cols-3 gap-4';
const inputBg = compact ? 'bg-[var(--dg-bg-tertiary)]' : 'bg-[var(--dg-bg-secondary)]';
return (
<div className={panelClass}>
<div>
<label className={labelClass}>
SSH 端口
<span className="text-[var(--dg-text-muted)] ml-1">(Linux 默认)</span>
</label>
<Input
type="number"
placeholder="22"
value={defaultSshPort}
onChange={(e) => onDefaultSshPortChange(e.target.value)}
className={`w-32 ${inputBg} border-[var(--dg-border)] text-[var(--dg-text-primary)]`}
/>
</div>
<div>
<label className={labelClass}>
Linux SSH 凭证
<span className="text-[var(--dg-text-muted)] ml-1">(可选)</span>
</label>
{sshCredentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">暂无 SSH 凭证</p>
) : (
<select
value={selectedSshCredentialId}
onChange={(e) => onSelectedSshCredentialIdChange(e.target.value)}
className={`w-full px-3 py-2 ${inputBg} border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm`}
>
<option value="">不使用凭证</option>
{sshCredentials.map((cred) => (
<option key={cred.id} value={String(cred.id)}>
{credentialLabel(cred)}
</option>
))}
</select>
)}
</div>
<div>
<label className={labelClass}>
Windows RDP 密码
<span className="text-[var(--dg-text-muted)] ml-1">(可选)</span>
</label>
{rdpCredentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">暂无 RDP 凭证</p>
) : (
<select
value={selectedRdpCredentialId}
onChange={(e) => onSelectedRdpCredentialIdChange(e.target.value)}
className={`w-full px-3 py-2 ${inputBg} border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm`}
>
<option value="">不使用凭证</option>
{rdpCredentials.map((cred) => (
<option key={cred.id} value={String(cred.id)}>
{credentialLabel(cred)}
</option>
))}
</select>
)}
</div>
</div>
);
}
@@ -0,0 +1,390 @@
import { useEffect, useRef, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import { getWsBase } from '@/utils/request';
import { useUserStore } from '@/store';
import { copyToClipboard } from '@/utils/clipboard';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { RdpToolsPanel } from './RdpToolsPanel';
import { Loader2, Maximize2, Minimize2, Wrench, X } from 'lucide-react';
interface RdpDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
serverId: number | null;
serverName: string;
serverIp: string;
}
interface RdpFrame {
type: string;
x?: number;
y?: number;
width?: number;
height?: number;
image?: string;
message?: string;
text?: string;
}
interface RdpStats {
received: number;
drawn: number;
failed: number;
}
const RDP_WIDTH = 1024;
const RDP_HEIGHT = 800;
const RDP_BINARY_HEADER = 20;
interface RdpRawFrame {
x: number;
y: number;
width: number;
height: number;
rgba: Uint8ClampedArray;
}
export function RdpDialog({ open, onOpenChange, serverId, serverName, serverIp }: RdpDialogProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const paintQueueRef = useRef<Promise<void>>(Promise.resolve());
const statsRef = useRef<RdpStats>({ received: 0, drawn: 0, failed: 0 });
const pendingMouseMoveRef = useRef<Record<string, unknown> | null>(null);
const mouseMoveTimerRef = useRef<number | null>(null);
const [connected, setConnected] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [status, setStatus] = useState('等待连接');
const [hasFrame, setHasFrame] = useState(false);
const [canvasSize, setCanvasSize] = useState({ width: RDP_WIDTH, height: RDP_HEIGHT });
const [displaySize, setDisplaySize] = useState({ width: RDP_WIDTH, height: RDP_HEIGHT });
const [toolsOpen, setToolsOpen] = useState(false);
const [remoteClipboard, setRemoteClipboard] = useState('');
const sendEvent = (event: Record<string, unknown>) => {
const ws = wsRef.current;
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(event));
}
};
const flushMouseMove = () => {
if (mouseMoveTimerRef.current !== null) {
window.clearTimeout(mouseMoveTimerRef.current);
mouseMoveTimerRef.current = null;
}
const event = pendingMouseMoveRef.current;
pendingMouseMoveRef.current = null;
if (event) sendEvent(event);
};
const sendMouseMove = (event: Record<string, unknown>) => {
pendingMouseMoveRef.current = event;
if (mouseMoveTimerRef.current !== null) return;
// 拖拽时只保留最新坐标,按 16ms 发送,避免旧 mousemove 堆积造成左右摆延迟。 // Logan.Li
mouseMoveTimerRef.current = window.setTimeout(() => {
mouseMoveTimerRef.current = null;
const latest = pendingMouseMoveRef.current;
pendingMouseMoveRef.current = null;
if (latest) sendEvent(latest);
}, 16);
};
const patchStats = (patch: Partial<RdpStats>) => {
statsRef.current = { ...statsRef.current, ...patch };
};
const paintBitmap = async (ctx: CanvasRenderingContext2D, frame: RdpFrame) => {
const dx = frame.x || 0;
const dy = frame.y || 0;
const dw = frame.width || 0;
const dh = frame.height || 0;
try {
if ('createImageBitmap' in window && frame.image) {
const blob = await fetch(frame.image).then((resp) => resp.blob());
const bitmap = await createImageBitmap(blob);
ctx.drawImage(bitmap, dx, dy, dw || bitmap.width, dh || bitmap.height);
bitmap.close();
return;
}
} catch {
// dataURL 在个别浏览器上可能解码失败,回退到 Image.onload 路径 // Logan.Li
}
await new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => {
ctx.drawImage(img, dx, dy, dw || img.width, dh || img.height);
resolve();
};
img.onerror = () => reject(new Error('RDP 位图解码失败'));
img.src = frame.image || '';
});
};
const parseRawFrame = async (data: Blob | ArrayBuffer): Promise<RdpRawFrame | null> => {
const buffer = data instanceof Blob ? await data.arrayBuffer() : data;
if (buffer.byteLength < RDP_BINARY_HEADER) return null;
const view = new DataView(buffer);
const magic =
String.fromCharCode(view.getUint8(0)) +
String.fromCharCode(view.getUint8(1)) +
String.fromCharCode(view.getUint8(2)) +
String.fromCharCode(view.getUint8(3));
if (magic !== 'RDPR') return null;
const x = view.getUint32(4, true);
const y = view.getUint32(8, true);
const width = view.getUint32(12, true);
const height = view.getUint32(16, true);
const bytes = width * height * 4;
if (width <= 0 || height <= 0 || buffer.byteLength < RDP_BINARY_HEADER + bytes) return null;
return {
x,
y,
width,
height,
rgba: new Uint8ClampedArray(buffer, RDP_BINARY_HEADER, bytes),
};
};
const drawRawFrame = async (data: Blob | ArrayBuffer) => {
const frame = await parseRawFrame(data);
if (!frame) return;
setStatus('');
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
ctx.imageSmoothingEnabled = false;
patchStats({ received: statsRef.current.received + 1 });
try {
const pixels = new Uint8ClampedArray(frame.rgba.length);
pixels.set(frame.rgba);
ctx.putImageData(new ImageData(pixels, frame.width, frame.height), frame.x, frame.y);
setHasFrame(true);
patchStats({ drawn: statsRef.current.drawn + 1 });
} catch {
patchStats({ failed: statsRef.current.failed + 1 });
setStatus('RDP 原始帧绘制失败');
}
};
const drawFrame = async (frame: RdpFrame) => {
if (frame.type === 'status' || frame.type === 'error') {
setStatus(frame.message || '');
return;
}
if (frame.type === 'session') {
setConnected(true);
if (frame.width && frame.height) setCanvasSize({ width: frame.width, height: frame.height });
return;
}
if (frame.type === 'clipboard') {
if (frame.text) {
setRemoteClipboard(frame.text);
await copyToClipboard(frame.text);
}
return;
}
if (frame.type !== 'bitmap' || !frame.image) return;
setStatus('');
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
ctx.imageSmoothingEnabled = false;
const received = statsRef.current.received + 1;
patchStats({ received });
try {
await paintBitmap(ctx, frame);
setHasFrame(true);
patchStats({ drawn: statsRef.current.drawn + 1 });
} catch {
patchStats({ failed: statsRef.current.failed + 1 });
setStatus(`RDP 位图绘制失败,已收 ${received} 帧`);
}
};
useEffect(() => {
if (!open || !serverId) return;
const token = useUserStore.getState().token || localStorage.getItem('token') || '';
const maxDisplayWidth = fullscreen ? window.innerWidth : Math.min(window.innerWidth * 0.94, RDP_WIDTH);
const maxDisplayHeight = fullscreen ? window.innerHeight - 41 : window.innerHeight - 96;
const scale = Math.min(maxDisplayWidth / RDP_WIDTH, maxDisplayHeight / RDP_HEIGHT, 1);
const displayWidth = Math.round(RDP_WIDTH * scale);
const displayHeight = Math.round(RDP_HEIGHT * scale);
// 固定 1024x800 会话尺寸,先排除分辨率变化对画质诊断的干扰。 // Logan.Li
const width = RDP_WIDTH;
const height = RDP_HEIGHT;
setDisplaySize({ width: displayWidth, height: displayHeight });
setCanvasSize({ width, height });
setStatus('正在创建 RDP 会话...');
setHasFrame(false);
setRemoteClipboard('');
statsRef.current = { received: 0, drawn: 0, failed: 0 };
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (canvas && ctx) {
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
const ws = new WebSocket(`${getWsBase()}/ws/rdp?id=${serverId}&width=${width}&height=${height}&token=${encodeURIComponent(token)}`);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onmessage = (event) => {
if (typeof event.data !== 'string') {
paintQueueRef.current = paintQueueRef.current
.catch(() => undefined)
.then(() => drawRawFrame(event.data as Blob | ArrayBuffer));
return;
}
try {
const frame = JSON.parse(event.data) as RdpFrame;
paintQueueRef.current = paintQueueRef.current
.catch(() => undefined)
.then(() => drawFrame(frame));
} catch {
setStatus(String(event.data || '收到无法解析的 RDP 帧'));
}
};
ws.onerror = () => setStatus('RDP 连接发生错误');
ws.onclose = () => {
setConnected(false);
setStatus((prev) => prev || 'RDP 连接已关闭');
};
return () => {
flushMouseMove();
ws.close();
wsRef.current = null;
paintQueueRef.current = Promise.resolve();
setConnected(false);
setToolsOpen(false);
};
}, [open, serverId, fullscreen]);
const canvasPoint = (event: React.MouseEvent<HTMLCanvasElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = Math.round((event.clientX - rect.left) * (canvasSize.width / rect.width));
const y = Math.round((event.clientY - rect.top) * (canvasSize.height / rect.height));
return { x, y };
};
const showConnectionIssue = Boolean(status && /失败|错误|关闭|无法解析/.test(status));
const showCenterLoading = open && !hasFrame && !showConnectionIssue;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={cn(
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0 overflow-hidden',
fullscreen
? '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
: '',
)}
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '1120px', width: '94vw' }}
>
<DialogTitle className="sr-only">RDP - {serverName}</DialogTitle>
<DialogDescription className="sr-only">Windows RDP 图形会话</DialogDescription>
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
<div className="flex items-center gap-2 text-sm">
<div className={cn('w-2 h-2 rounded-full', connected ? 'bg-emerald-400' : 'bg-red-400')} />
<span className="text-[var(--dg-text-primary)] font-medium">{serverName}</span>
<span className="text-[var(--dg-text-muted)]">({serverIp})</span>
</div>
<div className="flex items-center gap-1">
{serverId && (
<Popover open={toolsOpen} onOpenChange={setToolsOpen}>
<PopoverAnchor asChild>
<button
type="button"
onClick={() => setToolsOpen((v) => !v)}
className={cn(
'p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]',
toolsOpen && 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]',
)}
title="工具"
>
<Wrench className="h-4 w-4" />
</button>
</PopoverAnchor>
<PopoverContent align="end" className="p-0 w-auto border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<RdpToolsPanel
serverId={serverId}
remoteClipboard={remoteClipboard}
onSyncToRemote={(text) => {
sendEvent({ type: 'clipboard', text });
setStatus('已同步到远端');
}}
onAutoExec={(command) => {
sendEvent({ type: 'auto_exec', text: command });
setStatus('正在自动执行上传命令...');
}}
/>
</PopoverContent>
</Popover>
)}
<button
type="button"
onClick={() => setFullscreen(!fullscreen)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
<button
type="button"
onClick={() => onOpenChange(false)}
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div
className="relative bg-black flex items-center justify-center"
style={{ height: fullscreen ? 'calc(100vh - 41px)' : displaySize.height }}
>
<canvas
ref={canvasRef}
width={canvasSize.width}
height={canvasSize.height}
tabIndex={0}
className="max-w-full max-h-full outline-none"
style={{ width: displaySize.width, height: displaySize.height }}
onMouseMove={(e) => sendMouseMove({ type: 'mouse_move', ...canvasPoint(e) })}
onMouseDown={(e) => { e.preventDefault(); e.currentTarget.focus(); flushMouseMove(); sendEvent({ type: 'mouse_down', button: e.button, ...canvasPoint(e) }); }}
onMouseUp={(e) => { e.preventDefault(); flushMouseMove(); sendEvent({ type: 'mouse_up', button: e.button, ...canvasPoint(e) }); }}
onWheel={(e) => { flushMouseMove(); sendEvent({ type: 'wheel', delta_y: Math.trunc(e.deltaY), ...canvasPoint(e) }); }}
onContextMenu={(e) => e.preventDefault()}
onPaste={(e) => {
const text = e.clipboardData.getData('text/plain');
if (text) sendEvent({ type: 'clipboard', text });
}}
onKeyDown={(e) => { e.preventDefault(); sendEvent({ type: 'key', code: e.code, key: e.key, down: true }); }}
onKeyUp={(e) => { e.preventDefault(); sendEvent({ type: 'key', code: e.code, key: e.key, down: false }); }}
/>
{showCenterLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/25">
<Loader2 className="h-8 w-8 animate-spin text-white/80" />
</div>
)}
{showConnectionIssue && (
<div className="absolute inset-0 flex items-center justify-center bg-black/35 px-6 text-center text-sm text-white/85">
{status}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,225 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import { readFromClipboard } from '@/utils/clipboard';
import { rdpFilesApi, type RdpFileItem } from '@/api/server/rdp-files';
import { Button } from '@/components/ui/button';
import { Download, FolderOpen, RefreshCw, Settings2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { RDP_VIRTUAL_DOWNLOAD_ROOT } from './rdpConstants';
type ToolsTab = 'general' | 'files';
interface RdpToolsPanelProps {
serverId: number;
remoteClipboard: string;
onSyncToRemote: (text: string) => void;
onAutoExec: (command: string) => void;
}
function fmtSize(size: number): string {
if (size >= 1 << 20) return `${(size / (1 << 20)).toFixed(1)} MB`;
if (size >= 1 << 10) return `${(size / (1 << 10)).toFixed(1)} KB`;
return `${size} B`;
}
// RDP 工具面板:通用(剪贴板)+ 文件(SMB 直传,失败时自动回退)// Logan.Li
export function RdpToolsPanel({
serverId,
remoteClipboard,
onSyncToRemote,
onAutoExec,
}: RdpToolsPanelProps) {
const [tab, setTab] = useState<ToolsTab>('general');
const [localClipDraft, setLocalClipDraft] = useState('');
const [files, setFiles] = useState<RdpFileItem[]>([]);
const [loadingFiles, setLoadingFiles] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const refreshFiles = useCallback(async () => {
setLoadingFiles(true);
try {
const data = await rdpFilesApi.list(serverId);
setFiles(data?.list ?? []);
} catch (err) {
setFiles([]);
toast.error(err instanceof Error ? err.message : '无法列出远端文件(SMB 445 可能未开放)');
} finally {
setLoadingFiles(false);
}
}, [serverId]);
useEffect(() => {
if (tab === 'files') void refreshFiles();
}, [tab, refreshFiles]);
const fillLocalClipOnFocus = async () => {
const text = await readFromClipboard();
if (text) {
setLocalClipDraft(text);
return;
}
toast.info('无法自动读取剪贴板,请 Ctrl+V 粘贴(HTTPS 下可自动读取)');
};
const syncLocalClip = () => {
const trimmed = localClipDraft.trim();
if (!trimmed) {
toast.info('内容为空');
return;
}
onSyncToRemote(trimmed);
toast.success('已同步到远端');
};
const uploadWithFallback = async (file: File) => {
setUploading(true);
try {
try {
await rdpFilesApi.upload(serverId, file);
toast.success(`${file.name} 已上传到 Download`);
void refreshFiles();
return;
} catch {
// SMB 不可达时走 AIOps 暂存 + RDP 自动执行 // Logan.Li
}
const staged = await rdpFilesApi.stage(serverId, file);
onAutoExec(staged.deploy_command);
toast.success(`${file.name} 已自动下发(SMB 不可用时的回退模式)`);
} catch (err) {
toast.error(err instanceof Error ? err.message : '上传失败');
} finally {
setUploading(false);
}
};
const handleUploadPick = async (picked: File | undefined) => {
if (!picked) return;
await uploadWithFallback(picked);
setTab('files');
};
const handleDownload = async (name: string) => {
try {
await rdpFilesApi.download(serverId, name);
toast.success(`已开始下载 ${name}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : '下载失败');
}
};
return (
<div className="w-[380px]">
<div className="flex border-b border-[var(--dg-border)]">
<button
type="button"
onClick={() => setTab('general')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs transition-colors',
tab === 'general'
? 'text-[var(--dg-text-primary)] border-b-2 border-[var(--dg-blue)]'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]',
)}
>
<Settings2 className="h-3.5 w-3.5" />
通用
</button>
<button
type="button"
onClick={() => setTab('files')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs transition-colors',
tab === 'files'
? 'text-[var(--dg-text-primary)] border-b-2 border-[var(--dg-blue)]'
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]',
)}
>
<FolderOpen className="h-3.5 w-3.5" />
文件
</button>
</div>
{tab === 'general' && (
<div className="p-3 space-y-3">
<div className="space-y-1.5">
<label className="text-xs text-[var(--dg-text-muted)]">远端剪贴板</label>
<textarea
readOnly
value={remoteClipboard}
rows={3}
placeholder="远端复制内容将显示在此"
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 py-1.5 text-xs text-[var(--dg-text-secondary)] outline-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs text-[var(--dg-text-muted)]">同步到远端</label>
<textarea
value={localClipDraft}
onChange={(e) => setLocalClipDraft(e.target.value)}
onFocus={() => { void fillLocalClipOnFocus(); }}
onPaste={(e) => {
const pasted = e.clipboardData.getData('text/plain');
if (pasted) setLocalClipDraft(pasted);
}}
rows={3}
placeholder="点击输入框自动粘贴本地剪贴板"
className="w-full resize-none rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-2 py-1.5 text-xs text-[var(--dg-text-primary)] outline-none"
/>
<Button type="button" size="sm" className="w-full" onClick={syncLocalClip}>
同步到远端
</Button>
</div>
</div>
)}
{tab === 'files' && (
<div className="p-3 space-y-3">
<div className="flex items-center justify-between text-xs text-[var(--dg-text-muted)]">
<span>共享盘 &gt; <span className="text-[var(--dg-text-secondary)]">Download</span></span>
<button type="button" onClick={() => { void refreshFiles(); }} className="p-1 rounded hover:bg-[var(--dg-bg-hover)]" title="刷新">
<RefreshCw className={cn('h-3.5 w-3.5', loadingFiles && 'animate-spin')} />
</button>
</div>
<code className="block text-[10px] leading-relaxed text-[var(--dg-cyan)] bg-[var(--dg-bg-primary)] rounded px-2 py-1.5 break-all">
{RDP_VIRTUAL_DOWNLOAD_ROOT}
</code>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={(e) => {
void handleUploadPick(e.target.files?.[0]);
e.currentTarget.value = '';
}}
/>
<Button type="button" size="sm" className="w-full" disabled={uploading} onClick={() => fileInputRef.current?.click()}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
{uploading ? '上传中...' : '上传文件'}
</Button>
<div className="max-h-40 overflow-y-auto rounded border border-[var(--dg-border)]">
{files.length === 0 ? (
<p className="px-2 py-3 text-[10px] text-[var(--dg-text-muted)] text-center">
{loadingFiles ? '加载中...' : '暂无文件(需 SMB 445 可达才能列出)'}
</p>
) : (
files.map((f) => (
<div key={f.name} className="flex items-center justify-between gap-2 px-2 py-1.5 text-xs border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-hover)]">
<div className="min-w-0 flex-1">
<p className="truncate text-[var(--dg-text-primary)]">{f.name}</p>
<p className="text-[10px] text-[var(--dg-text-muted)]">{fmtSize(f.size)}</p>
</div>
<button type="button" onClick={() => { void handleDownload(f.name); }} className="p-1 rounded hover:bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)]" title="下载">
<Download className="h-3.5 w-3.5" />
</button>
</div>
))
)}
</div>
<p className="text-[10px] text-[var(--dg-text-muted)] leading-relaxed">
优先 SMB 直传(一键);若 445 不通则自动暂存到 AIOps 并由 RDP 执行下载命令。
</p>
</div>
)}
</div>
);
}
+108 -42
View File
@@ -27,7 +27,6 @@ import {
import { Server, ChevronDown, Loader2, Save, Key, Shield } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { credentialApi } from '@/api';
import { useTranslation } from 'react-i18next';
import type { JsonData } from '@/types/common';
// 表单数据结构
@@ -38,6 +37,9 @@ export interface ServerFormData {
port: string;
username: string;
auth_type: string;
os_type: string;
protocol: string;
rdp_port: string;
password: string;
ssh_key: string;
credential_id: number | null;
@@ -57,6 +59,9 @@ export const EMPTY_FORM: ServerFormData = {
port: '22',
username: 'root',
auth_type: 'password',
os_type: 'linux',
protocol: 'ssh',
rdp_port: '3389',
password: '',
ssh_key: '',
credential_id: null,
@@ -86,6 +91,9 @@ interface Credential {
name: string;
username: string;
auth_type: string;
type?: string;
secret_type?: string;
summary?: string;
}
export function ServerForm({
@@ -99,15 +107,19 @@ export function ServerForm({
onSave,
formSaving,
}: ServerFormProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [credentials, setCredentials] = useState<Credential[]>([]);
// 只显示 SSH 相关的凭证(ssh key / ssh password)
const sshCredentials = credentials.filter((cred: any) => {
// 按连接协议过滤凭证:SSH 和 RDP 分开展示,避免 Windows 误选 SSH 凭据 // Logan.Li
const sshCredentials = credentials.filter((cred) => {
const at = (cred.auth_type || cred.type || cred.secret_type || '').toLowerCase();
return at === 'key' || at === 'password' || at === 'ssh_key' || at === 'ssh_password' || at.includes('ssh');
});
const rdpCredentials = credentials.filter((cred) => {
const at = (cred.auth_type || cred.type || cred.secret_type || '').toLowerCase();
return at === 'rdp' || at.includes('rdp');
});
const selectableCredentials = formData.protocol === 'rdp' ? rdpCredentials : sshCredentials;
// 加载凭证列表
useEffect(() => {
@@ -141,16 +153,28 @@ export function ServerForm({
}
};
const handleProtocolChange = (protocol: string) => {
setFormData({
...formData,
protocol,
os_type: protocol === 'rdp' ? 'windows' : 'linux',
port: protocol === 'rdp' ? '22' : (formData.port || '22'),
rdp_port: protocol === 'rdp' ? (formData.rdp_port || '3389') : formData.rdp_port,
username: protocol === 'rdp' ? 'Administrator' : 'root',
credential_id: null,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Server className="h-5 w-5 text-[var(--dg-blue)]" />
{editingId ? t('server.editTitle') : t('server.addServer')}
{editingId ? '编辑服务器' : '添加服务器'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
{editingId ? t('server.editSubtitle') : t('server.addSubtitle')}
{editingId ? '修改服务器资产信息' : '录入新的服务器资产到 CMDB'}
</DialogDescription>
</DialogHeader>
@@ -159,7 +183,7 @@ export function ServerForm({
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">
{t('server.hostname')} <span className="dg-text-red">*</span>
主机名 <span className="dg-text-red">*</span>
</Label>
<Input
placeholder="web-server-01"
@@ -170,7 +194,7 @@ export function ServerForm({
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">
{t('server.ipAddress')} <span className="dg-text-red">*</span>
IP地址 <span className="dg-text-red">*</span>
</Label>
<Input
placeholder="10.0.0.1"
@@ -181,33 +205,75 @@ export function ServerForm({
</div>
</div>
{/* SSH 连接配置 */}
{/* 连接协议 */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">系统类型</Label>
<Select
value={formData.os_type}
onValueChange={(v) => setFormData({ ...formData, os_type: v, protocol: v === 'windows' ? 'rdp' : 'ssh' })}
>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="linux">Linux</SelectItem>
<SelectItem value="windows">Windows</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">连接协议</Label>
<Select value={formData.protocol} onValueChange={handleProtocolChange}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="ssh">SSH</SelectItem>
<SelectItem value="rdp">RDP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{formData.protocol === 'rdp' ? 'RDP端口' : 'SSH端口'}</Label>
<Input
type="number"
placeholder={formData.protocol === 'rdp' ? '3389' : '22'}
value={formData.protocol === 'rdp' ? formData.rdp_port : formData.port}
onChange={(e) => setFormData(formData.protocol === 'rdp'
? { ...formData, rdp_port: e.target.value }
: { ...formData, port: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div>
{/* 连接配置 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.publicIp')}</Label>
<Label className="text-[var(--dg-text-secondary)]">公网IP</Label>
<Input
placeholder={t('server.publicIpPlaceholder')}
placeholder="如 203.0.113.10"
value={formData.public_ip}
onChange={(e) => setFormData({ ...formData, public_ip: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.sshPort')}</Label>
<Label className="text-[var(--dg-text-secondary)]">登录用户</Label>
<Input
type="number"
placeholder="22"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: e.target.value })}
placeholder={formData.protocol === 'rdp' ? 'Administrator' : 'root'}
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
</div>
{/* SSH 凭证关联 — 只允许 SSH 密钥 / SSH 密码 类型 */}
{/* 凭证关联 — SSH/RDP 按协议过滤 */}
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)] flex items-center gap-1.5">
<Key className="h-3.5 w-3.5" /> {t('server.sshCredential')}
<Key className="h-3.5 w-3.5" /> {formData.protocol === 'rdp' ? 'RDP 凭证' : 'SSH 凭证'}
{!editingId && !formData.credential_id && <span className="dg-text-red ml-1">*</span>}
</Label>
<Select
@@ -215,23 +281,23 @@ export function ServerForm({
onValueChange={handleCredentialSelect}
>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder={t('server.selectCredential')} />
<SelectValue placeholder={formData.protocol === 'rdp' ? '选择 Windows RDP 密码...' : '选择 SSH 凭证...'} />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
{sshCredentials.map((cred) => (
{selectableCredentials.map((cred) => (
<SelectItem key={cred.id} value={String(cred.id)}>
<span className="flex items-center gap-2">
<Shield className="h-3 w-3 dg-text-blue" />
{cred.name} ({cred.username}@{ (cred.auth_type || '').toLowerCase() === 'key' ? t('server.authKey') : t('server.authPassword')})
{cred.name}{cred.summary ? ` (${cred.summary})` : ''}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{sshCredentials.length === 0 && (
{selectableCredentials.length === 0 && (
<p className="text-xs text-[var(--dg-text-muted)]">
{t('server.noCredential')}
暂无可用的 {formData.protocol === 'rdp' ? 'Windows RDP 密码' : 'SSH 凭证'}。
<span
className="text-[var(--dg-blue)] hover:underline cursor-pointer ml-1"
onClick={() => {
@@ -239,13 +305,13 @@ export function ServerForm({
navigate('/credentials');
}}
>
{t('server.goCreateCredential')}
去系统管理 - 凭证配置创建
</span>
</p>
)}
{formData.credential_id && (
<p className="text-[10px] dg-text-green/70">{t('server.credentialLinked')}</p>
<p className="text-[10px] dg-text-green/70">已关联凭证,登录信息从凭证自动获取(登录用户和认证方式由凭证决定)</p>
)}
</div>
@@ -259,7 +325,7 @@ export function ServerForm({
>
<span className="flex items-center gap-2">
<Server className="h-4 w-4" />
{t('server.moreDetails')}
更多详情(OS版本 / 运行状态 / CPU / 内存 / 磁盘 / 标签 / 描述)
</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', detailsOpen && 'rotate-180')} />
</Button>
@@ -267,16 +333,16 @@ export function ServerForm({
<CollapsibleContent className="space-y-4 pt-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.os')}</Label>
<Label className="text-[var(--dg-text-secondary)]">操作系统</Label>
<Input
placeholder={t('server.osPlaceholder')}
placeholder="如 CentOS 7.9"
value={formData.os}
onChange={(e) => setFormData({ ...formData, os: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)]"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.runStatus')}</Label>
<Label className="text-[var(--dg-text-secondary)]">运行状态</Label>
<Select
value={formData.status}
onValueChange={(v) => setFormData({ ...formData, status: v })}
@@ -285,17 +351,17 @@ export function ServerForm({
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<SelectItem value="running">{t('common.running')}</SelectItem>
<SelectItem value="stopped">{t('common.stopped')}</SelectItem>
<SelectItem value="maintenance">{t('common.maintenance')}</SelectItem>
<SelectItem value="offline">{t('common.offline')}</SelectItem>
<SelectItem value="running">运行中</SelectItem>
<SelectItem value="stopped">已停止</SelectItem>
<SelectItem value="maintenance">维护中</SelectItem>
<SelectItem value="offline">离线</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.cpuCores')}</Label>
<Label className="text-[var(--dg-text-secondary)]">CPU核数</Label>
<Input
type="number"
placeholder="4"
@@ -305,7 +371,7 @@ export function ServerForm({
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.memoryGb')}</Label>
<Label className="text-[var(--dg-text-secondary)]">内存 (GB)</Label>
<Input
type="number"
placeholder="8"
@@ -315,7 +381,7 @@ export function ServerForm({
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.diskGb')}</Label>
<Label className="text-[var(--dg-text-secondary)]">磁盘 (GB)</Label>
<Input
type="number"
placeholder="100"
@@ -326,18 +392,18 @@ export function ServerForm({
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('server.tags')}</Label>
<Label className="text-[var(--dg-text-secondary)]">标签</Label>
<Input
placeholder={t('server.tagsPlaceholder')}
placeholder='JSON 格式,如 {"project":"web","team":"ops"}'
value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] font-mono text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[var(--dg-text-secondary)]">{t('common.description')}</Label>
<Label className="text-[var(--dg-text-secondary)]">描述</Label>
<Textarea
placeholder={t('server.descPlaceholder')}
placeholder="补充说明..."
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] min-h-[60px]"
@@ -353,7 +419,7 @@ export function ServerForm({
onClick={() => onOpenChange(false)}
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]"
>
{t('common.cancel')}
取消
</Button>
<Button
onClick={onSave}
@@ -365,7 +431,7 @@ export function ServerForm({
) : (
<Save className="h-4 w-4 mr-2" />
)}
{editingId ? t('server.saveChanges') : t('common.create')}
{editingId ? '保存修改' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
@@ -47,6 +47,7 @@ import { toast } from 'sonner';
import { useUserStore } from '@/store';
import { CloudServerImport } from './CloudServerImport';
import { TerminalDialog } from './TerminalDialog';
import { RdpDialog } from './RdpDialog';
import { ServerFileBrowser } from './ServerFileBrowser';
import type { JsonData } from '@/types/common';
import { ExcelServerImport } from './ExcelServerImport';
@@ -79,6 +80,8 @@ export function ServerManager() {
// 终端 Dialog 状态
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string; hasAuth?: boolean } | null>(null);
const [rdpOpen, setRdpOpen] = useState(false);
const [rdpServer, setRdpServer] = useState<{ id: number; name: string; ip: string } | null>(null);
// 文件浏览 Dialog 状态
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
@@ -105,6 +108,11 @@ export function ServerManager() {
setTerminalOpen(true);
};
const openRdp = (server: JsonData) => {
setRdpServer({ id: server.id, name: server.hostname, ip: server.ip });
setRdpOpen(true);
};
const openFileBrowser = (server: JsonData) => {
setFileBrowserServer({ id: server.id, name: server.hostname });
setFileBrowserOpen(true);
@@ -272,6 +280,9 @@ export function ServerManager() {
port: String(server.port || 22),
username: server.username || 'root',
auth_type: server.auth_type || 'password',
os_type: server.os_type || ((server.protocol === 'rdp') ? 'windows' : 'linux'),
protocol: server.protocol || ((server.os_type === 'windows') ? 'rdp' : 'ssh'),
rdp_port: String(server.rdp_port || 3389),
password: '',
ssh_key: '',
credential_id: null as number | null,
@@ -297,6 +308,9 @@ export function ServerManager() {
ip: formData.ip,
public_ip: formData.public_ip || undefined,
port: Number(formData.port) || 22,
os_type: formData.os_type || 'linux',
protocol: formData.protocol || 'ssh',
rdp_port: Number(formData.rdp_port) || 3389,
username: formData.username || 'root',
auth_type: formData.auth_type || 'password',
os: formData.os,
@@ -500,6 +514,7 @@ export function ServerManager() {
selected={selectedServers.includes(String(server.id))}
onToggleSelect={toggleSelect}
onOpenTerminal={openTerminal}
onOpenRdp={openRdp}
onOpenFiles={openFileBrowser}
onOpenEdit={openEditForm}
onDelete={handleDelete}
@@ -565,6 +580,13 @@ export function ServerManager() {
onSave={handleSave}
formSaving={formSaving}
/>
<RdpDialog
open={rdpOpen}
onOpenChange={setRdpOpen}
serverId={rdpServer?.id ?? null}
serverName={rdpServer?.name || ''}
serverIp={rdpServer?.ip || ''}
/>
{/* Import Dialogs */}
<CloudServerImport
@@ -12,17 +12,17 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TableCell, TableRow } from '@/components/ui/table';
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen } from 'lucide-react';
import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive, FolderOpen, Monitor } from 'lucide-react';
import { getStatusBadge, getTags, getUsageColor, isRdpServer } from './serverHelpers';
import { usePermission } from '@/hooks/usePermission';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
interface ServerTableRowProps {
server?: JsonData;
selected: boolean;
onToggleSelect: (id: string) => void;
onOpenTerminal: (server: JsonData) => void;
onOpenRdp: (server: JsonData) => void;
onOpenEdit: (server: JsonData) => void;
onOpenFiles?: (server: JsonData) => void;
onDelete: (id: string) => void;
@@ -33,6 +33,7 @@ export function ServerTableRow({
selected,
onToggleSelect,
onOpenTerminal,
onOpenRdp,
onOpenEdit,
onOpenFiles,
onDelete,
@@ -48,6 +49,7 @@ export function ServerTableRow({
const _txBps = server.net_tx_bps || 0;
const readBps = server.disk_read_bps || 0;
const writeBps = server.disk_write_bps || 0;
const isRdp = isRdpServer(server);
// Format bytes/s to human readable
const fmtBps = (bps: number) => {
@@ -110,7 +112,7 @@ export function ServerTableRow({
<span className="text-xs text-[var(--dg-text-secondary)]">{cpuUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--dg-text-muted)] w-8"> {i18n.t('kubernetes.pod.memory')} </span>
<span className="text-xs text-[var(--dg-text-muted)] w-8">内存</span>
<div className="w-16 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full', getUsageColor(memoryUsage))}
@@ -151,8 +153,8 @@ export function ServerTableRow({
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{hasPermission('server:webshell') && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)} title={i18n.t('server.servertablerowt.sa0b6010a')}>
<Terminal className="h-4 w-4" />
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => isRdp ? onOpenRdp(server) : onOpenTerminal(server)} title={isRdp ? 'RDP登录' : '终端登录'}>
{isRdp ? <Monitor className="h-4 w-4" /> : <Terminal className="h-4 w-4" />}
</Button>
)}
{(hasPermission('server:update') || hasPermission('server:delete') || hasPermission('server:webshell')) && (
@@ -163,7 +165,7 @@ export function ServerTableRow({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]"> {i18n.t('system.auditAction')} </DropdownMenuLabel>
<DropdownMenuLabel className="text-[var(--dg-text-secondary)]">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
{hasPermission('server:update') && (
<DropdownMenuItem
@@ -177,10 +179,10 @@ export function ServerTableRow({
{hasPermission('server:webshell') && (
<DropdownMenuItem
className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer"
onClick={() => onOpenTerminal(server)}
onClick={() => isRdp ? onOpenRdp(server) : onOpenTerminal(server)}
>
<Terminal className="h-4 w-4 mr-2" />
终端
{isRdp ? <Monitor className="h-4 w-4 mr-2" /> : <Terminal className="h-4 w-4 mr-2" />}
{isRdp ? 'RDP' : '终端'}
</DropdownMenuItem>
)}
{hasPermission('server:update') && (
@@ -12,11 +12,11 @@ import 'xterm/css/xterm.css';
import { Maximize2, Minimize2, X, Shield} from 'lucide-react';
import { cn } from '@/lib/utils';
import { attachTerminalResize, fitTerminal, mountXterm } from '@/lib/xterm-utils';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
import { useUserStore } from '@/store';
import { credentialApi, serverCredentialApi } from '@/api';
import { toast } from 'sonner';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
interface TerminalDialogProps {
open: boolean;
@@ -46,6 +46,7 @@ export function TerminalDialog({
const fitRef = useRef<FitAddon | null>(null);
const [connected, setConnected] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [hasOutput, setHasOutput] = useState(false);
// 凭据选择状态(hasAuth=false 时显示) // Logan.Li
const [credentials, setCredentials] = useState<JsonData[]>([]);
@@ -65,10 +66,10 @@ export function TerminalDialog({
try {
setAssigning(true);
await serverCredentialApi.assign(serverId, Number(selectedCredId));
toast.success(i18n.t('server.terminaldialogt.s7c9109af'));
toast.success('凭据已关联');
setAuthReady(true);
} catch (e) {
toast.error(e?.message || i18n.t('server.terminaldialogt.s6d973dbe'));
toast.error(e?.message || '关联失败');
} finally {
setAssigning(false);
}
@@ -85,6 +86,7 @@ export function TerminalDialog({
// 连接终端(authReady=true 时才执行)
useEffect(() => {
if (!open || !serverId || !authReady) return;
setHasOutput(false);
// 创建终端
const term = new XTerm({
@@ -121,9 +123,11 @@ export function TerminalDialog({
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
setHasOutput(true);
term.write(msg.data);
}
} catch {
setHasOutput(true);
term.write(typeof event.data === 'string' ? event.data : '');
}
};
@@ -136,11 +140,13 @@ export function TerminalDialog({
ws.onclose = () => {
setConnected(false);
term.write(i18n.t('server.terminaldialogt.safab2019'));
setHasOutput(true);
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
};
ws.onerror = () => {
term.write(i18n.t('kubernetes.podexecterminal.sc8107726'));
setHasOutput(true);
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
return () => {
@@ -227,11 +233,11 @@ export function TerminalDialog({
<div className="p-6 flex flex-col items-center justify-center gap-4" style={{ height: '480px' }}>
<Shield className="h-12 w-12 dg-text-yellow" />
<div className="text-center">
<p className="text-[var(--dg-text-primary)] font-medium mb-1"> {i18n.t('server.terminaldialogt.sa8156f58')} </p>
<p className="text-sm text-[var(--dg-text-muted)]"> {i18n.t('server.terminaldialogt.s909c9a65')} </p>
<p className="text-[var(--dg-text-primary)] font-medium mb-1">服务器未配置认证凭据</p>
<p className="text-sm text-[var(--dg-text-muted)]">请选择一个凭据用于 SSH 连接</p>
</div>
{credentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)]"> {i18n.t('server.terminaldialogt.s817bf0a2')} </p>
<p className="text-xs text-[var(--dg-text-muted)]">暂无可用凭据,请先去「凭证配置」添加</p>
) : (
<select
value={selectedCredId}
@@ -251,17 +257,16 @@ export function TerminalDialog({
disabled={!selectedCredId || assigning}
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
>
{assigning ? i18n.t('server.terminaldialogt.saf3d5dd8') : i18n.t('server.terminaldialogt.s9b448012')}
{assigning ? '关联中...' : '确认连接'}
</Button>
</div>
)}
{/* 终端容器 */}
{authReady && (
<div
ref={containerRef}
className="xterm-terminal-host"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '520px' }}
/>
<div className="relative bg-[#0a0e14]" style={{ height: fullscreen ? 'calc(100vh - 49px)' : '520px' }}>
<div ref={containerRef} className="xterm-terminal-host h-full" />
<TerminalLoadingOverlay show={!hasOutput} label="正在登录..." />
</div>
)}
</DialogContent>
</Dialog>
+38 -38
View File
@@ -29,10 +29,11 @@ import {
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { attachTerminalResize, fitTerminal, mountXterm } from '@/lib/xterm-utils';
import { TerminalLoadingOverlay } from '@/components/common/TerminalLoadingOverlay';
import { serverApi } from '@/api';
import { useUserStore } from '@/store';
import type { JsonData } from '@/types/common';
import i18n from '@/i18n';
interface TerminalSession {
id: string;
@@ -40,6 +41,7 @@ interface TerminalSession {
serverName: string;
serverIp: string;
connected: boolean;
ready: boolean;
}
interface TermEntry {
@@ -47,6 +49,7 @@ interface TermEntry {
ws: WebSocket;
fit: FitAddon;
opened: boolean;
resizeCleanup?: () => void;
}
export function WebShell() {
@@ -67,7 +70,7 @@ export function WebShell() {
const res: JsonData = await serverApi.getServers();
setServers(res?.list || []);
} catch (e) {
console.error(i18n.t('server.webshelltsx.s8603ff5b'), e);
console.error('加载服务器列表失败:', e);
setServers([]);
}
};
@@ -79,8 +82,9 @@ export function WebShell() {
// 卸载时关闭所有连接
useEffect(() => {
return () => {
termsRef.current.forEach(({ ws, term }) => {
termsRef.current.forEach(({ ws, term, resizeCleanup }) => {
try {
resizeCleanup?.();
ws.close();
term.dispose();
} catch {
@@ -135,10 +139,12 @@ export function WebShell() {
try {
const msg = JSON.parse(event.data);
if (msg.status === 0 && typeof msg.data === 'string') {
setSessions((prev) => prev.map((s) => (s.id === sessionId ? { ...s, ready: true } : s)));
term.write(msg.data);
}
} catch {
// 非 JSON,直接写入原始数据
setSessions((prev) => prev.map((s) => (s.id === sessionId ? { ...s, ready: true } : s)));
term.write(typeof event.data === 'string' ? event.data : '');
}
};
@@ -148,14 +154,15 @@ export function WebShell() {
};
ws.onclose = () => {
term.write(i18n.t('server.terminaldialogt.safab2019'));
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
setSessions((prev) =>
prev.map((s) => (s.id === sessionId ? { ...s, connected: false } : s))
prev.map((s) => (s.id === sessionId ? { ...s, connected: false, ready: true } : s))
);
};
ws.onerror = () => {
term.write(i18n.t('kubernetes.podexecterminal.sc8107726'));
setSessions((prev) => prev.map((s) => (s.id === sessionId ? { ...s, ready: true } : s)));
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
};
termsRef.current.set(sessionId, { term, ws, fit, opened: false });
@@ -166,6 +173,7 @@ export function WebShell() {
serverName: server.hostname,
serverIp: server.ip,
connected: true,
ready: false,
};
setSessions((prev) => [...prev, newSession]);
setActiveSession(sessionId);
@@ -177,6 +185,7 @@ export function WebShell() {
const entry = termsRef.current.get(sessionId);
if (entry) {
try {
entry.resizeCleanup?.();
if (entry.ws.readyState === WebSocket.OPEN || entry.ws.readyState === WebSocket.CONNECTING) {
entry.ws.close();
}
@@ -201,12 +210,7 @@ export function WebShell() {
const entry = termsRef.current.get(activeSession);
if (!entry || !entry.opened) return;
try {
entry.fit.fit();
const { cols, rows } = entry.term;
entry.term.resize(cols, rows);
if (entry.ws.readyState === WebSocket.OPEN) {
entry.ws.send(JSON.stringify({ status: 1, cols, rows }));
}
fitTerminal(entry.fit, entry.term, entry.ws);
} catch {
/* noop */
}
@@ -222,7 +226,7 @@ export function WebShell() {
if (entry && entry.opened) {
requestAnimationFrame(() => {
try {
entry.fit.fit();
fitTerminal(entry.fit, entry.term, entry.ws);
entry.term.focus();
} catch {
/* noop */
@@ -248,27 +252,16 @@ export function WebShell() {
const entry = termsRef.current.get(sessionId);
if (entry && !entry.opened) {
try {
entry.term.open(el);
mountXterm(entry.term, entry.fit, () => el, entry.ws);
entry.opened = true;
// 延迟适配:等 DOM 布局完全确定
setTimeout(() => {
try { entry.fit.fit(); entry.term.focus(); } catch { /* noop */ }
}, 100);
// ResizeObserver 持续监听容器大小变化
const observer = new ResizeObserver(() => {
try { entry.fit.fit(); } catch { /* noop */ }
});
observer.observe(el);
(entry as JsonData)._observer = observer;
entry.resizeCleanup = attachTerminalResize(el, entry.fit, entry.term, entry.ws);
} catch (e) {
console.error(i18n.t('server.webshelltsx.s7d4b03bb'), e);
console.error('打开终端失败:', e);
}
}
} else {
const entry = termsRef.current.get(sessionId);
if (entry && (entry as JsonData)._observer) {
(entry as JsonData)._observer.disconnect();
}
entry?.resizeCleanup?.();
containerRef.current.delete(sessionId);
}
};
@@ -283,7 +276,7 @@ export function WebShell() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">WebShell</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1"> {i18n.t('server.webshelltsx.s1df74087')} </p>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">远程服务器终端管理</p>
</div>
<div className="flex items-center gap-2">
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
@@ -295,21 +288,21 @@ export function WebShell() {
</DialogTrigger>
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]"> {i18n.t('server.webshelltsx.s7026b318')} </DialogTitle>
<DialogTitle className="text-[var(--dg-text-primary)]">新建终端连接</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">
选择要连接的服务器
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--dg-text-secondary)]"> {i18n.t('server.webshelltsx.s030235d1')} </label>
<label className="text-sm font-medium text-[var(--dg-text-secondary)]">选择服务器</label>
<Select value={selectedServer} onValueChange={setSelectedServer}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue placeholder={i18n.t('server.webshelltsx.s030235d1')} />
<SelectValue placeholder="选择服务器" />
</SelectTrigger>
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
{runningServers.length === 0 ? (
<SelectItem value="__none" disabled> {i18n.t('server.webshelltsx.se5460be8')} </SelectItem>
<SelectItem value="__none" disabled>暂无运行中的服务器</SelectItem>
) : (
runningServers.map((server) => (
<SelectItem key={server.id} value={server.id}>
@@ -379,7 +372,7 @@ export function WebShell() {
))}
</div>
) : (
<span className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('server.webshelltsx.sd5d6cbbb')} </span>
<span className="text-sm text-[var(--dg-text-secondary)]">暂无连接</span>
)}
</div>
<div className="flex items-center gap-1">
@@ -407,12 +400,19 @@ export function WebShell() {
key={session.id}
ref={(el) => registerContainer(session.id, el)}
className={cn(
'absolute inset-0',
'absolute inset-0 xterm-terminal-host',
activeSession === session.id ? 'block' : 'hidden'
)}
style={{ padding: 0 }}
/>
))}
{sessions.map((session) => (
<TerminalLoadingOverlay
key={`${session.id}-loading`}
show={activeSession === session.id && !session.ready}
label="正在登录..."
/>
))}
</div>
) : (
<div
@@ -420,8 +420,8 @@ export function WebShell() {
style={{ height: terminalHeight }}
>
<Terminal className="h-16 w-16 mb-4 opacity-50" />
<p className="text-lg font-medium"> {i18n.t('server.webshelltsx.sefd92a06')} </p>
<p className="text-sm mt-1"> {i18n.t('server.webshelltsx.se2e43347')} </p>
<p className="text-lg font-medium">暂无活动连接</p>
<p className="text-sm mt-1">点击“新建连接”开始远程会话</p>
</div>
)}
</CardContent>
@@ -431,7 +431,7 @@ export function WebShell() {
{activeSessionData && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-[var(--dg-text-secondary)]"> {i18n.t('server.webshelltsx.sde027789')} </CardTitle>
<CardTitle className="text-sm text-[var(--dg-text-secondary)]">快捷命令</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
@@ -0,0 +1,7 @@
// RDP 虚拟共享盘根路径,映射为 Windows 本机 Download 目录 // Logan.Li
export const RDP_VIRTUAL_DOWNLOAD_ROOT = 'C:\\Users\\Public\\AIOpsShare\\Download';
export function rdpVirtualDownloadPath(fileName: string): string {
const safe = fileName.replace(/[\\/:*?"<>|]/g, '_').trim() || 'upload.bin';
return `${RDP_VIRTUAL_DOWNLOAD_ROOT}\\${safe}`;
}
@@ -3,6 +3,14 @@ import { Badge } from '@/components/ui/badge';
import type { Server as ServerType } from '@/types';
import type { JsonData } from '@/types/common';
// isRdpServer 判断资产是否应展示 RDP 入口(兼容云导入仅填 os 字段的历史数据) // Logan.Li
export function isRdpServer(server: JsonData): boolean {
const protocol = String(server.protocol || '').toLowerCase();
if (protocol === 'rdp') return true;
const osType = String(server.os_type || server.os || '').toLowerCase();
return osType.includes('windows') || osType.includes('win');
}
// 解析标签:后端 tags 可能是 JSON 字符串(JSON数组/对象)、逗号分隔字符串或数组 // Logan.Li
export function getTags(server: JsonData): string[] {
const tags = server.tags;