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

@@ -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>
);
}