// RDP 审计查看器:在线实时查看 + 历史录屏回放共用 Canvas 播放器 // Logan.Li import { useEffect, useMemo, useRef, useState } from 'react'; import { Dialog, DialogContent, DialogDescription, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; import { cn } from '@/lib/utils'; import { getAuthToken, getWsBase } from '@/utils/request'; import { rdpSessionApi } from '@/api'; import { Loader2, Maximize2, Minimize2, Pause, Play, RotateCcw, X } from 'lucide-react'; import i18n from '@/i18n'; interface RdpAuditViewerProps { open: boolean; onOpenChange: (open: boolean) => void; sessionId: number | null; mode: 'live' | 'replay'; } interface RdpFrame { type: string; x?: number; y?: number; width?: number; height?: number; image?: string; message?: string; } interface RdpRecord { version?: number; frames?: RdpFrame[]; } interface RawFrame { x: number; y: number; width: number; height: number; rgba: Uint8ClampedArray; } const BINARY_HEADER = 20; const FRAME_MS = 500; async function parseRawFrame(data: Blob | ArrayBuffer): Promise { const buffer = data instanceof Blob ? await data.arrayBuffer() : data; if (buffer.byteLength < BINARY_HEADER) return null; const view = new DataView(buffer); const magic = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), 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 < BINARY_HEADER + bytes) return null; return { x, y, width, height, rgba: new Uint8ClampedArray(buffer, BINARY_HEADER, bytes) }; } async function drawImageFrame(ctx: CanvasRenderingContext2D, frame: RdpFrame) { if (!frame.image) return; await new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { ctx.drawImage(img, frame.x || 0, frame.y || 0, frame.width || img.width, frame.height || img.height); resolve(); }; img.onerror = () => reject(new Error(i18n.t('system.rdpFrameError'))); img.src = frame.image || ''; }); } export function RdpAuditViewer({ open, onOpenChange, sessionId, mode }: RdpAuditViewerProps) { const canvasRef = useRef(null); const wsRef = useRef(null); const timerRef = useRef(null); const replaySeqRef = useRef(0); const [fullscreen, setFullscreen] = useState(false); const [status, setStatus] = useState(''); const [loading, setLoading] = useState(false); const [canvasSize, setCanvasSize] = useState({ width: 1024, height: 800 }); const [frames, setFrames] = useState([]); const [index, setIndex] = useState(0); const [playing, setPlaying] = useState(false); const playableFrames = useMemo(() => frames.filter((f) => f.type === 'bitmap' && f.image), [frames]); const drawRaw = async (data: Blob | ArrayBuffer) => { const frame = await parseRawFrame(data); if (!frame) return; const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!canvas || !ctx) return; ctx.imageSmoothingEnabled = false; const pixels = new Uint8ClampedArray(frame.rgba.length); pixels.set(frame.rgba); ctx.putImageData(new ImageData(pixels, frame.width, frame.height), frame.x, frame.y); }; const clearReplayCanvas = () => { const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (!canvas || !ctx) return null; ctx.imageSmoothingEnabled = false; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height); return ctx; }; const drawReplay = async (nextIndex: number, rebuild = false) => { const ctx = rebuild ? clearReplayCanvas() : canvasRef.current?.getContext('2d'); if (!ctx) return; ctx.imageSmoothingEnabled = false; const start = rebuild ? 0 : nextIndex; for (let i = start; i <= nextIndex; i += 1) { const frame = playableFrames[i]; if (frame) await drawImageFrame(ctx, frame); } }; useEffect(() => { if (!open || !sessionId) return; setStatus(mode === 'live' ? i18n.t('monitor.rdp.connectingLive') : i18n.t('monitor.rdp.loadingRecording')); setLoading(true); setFrames([]); setIndex(0); setPlaying(false); const canvas = canvasRef.current; const ctx = canvas?.getContext('2d'); if (canvas && ctx) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#000'; ctx.fillRect(0, 0, canvas.width, canvas.height); } if (mode === 'live') { const token = getAuthToken(); const ws = new WebSocket(`${getWsBase()}/ws/rdp-audit/${sessionId}?token=${encodeURIComponent(token)}`); ws.binaryType = 'arraybuffer'; wsRef.current = ws; ws.onopen = () => { setLoading(false); setStatus(''); }; ws.onmessage = (event) => { if (typeof event.data !== 'string') { void drawRaw(event.data as Blob | ArrayBuffer); return; } try { const frame = JSON.parse(event.data) as RdpFrame; if (frame.type === 'session' && frame.width && frame.height) { setCanvasSize({ width: frame.width, height: frame.height }); } else if (frame.type === 'status' || frame.type === 'error') { setStatus(frame.message || ''); } } catch { /* ignore */ } }; ws.onerror = () => { setLoading(false); setStatus(i18n.t('monitor.rdp.liveConnFailed')); }; ws.onclose = () => setStatus((prev) => prev || i18n.t('monitor.rdp.liveDisconnected')); return () => { ws.close(); wsRef.current = null; }; } let cancelled = false; rdpSessionApi.getRecord(sessionId) .then((raw) => { if (cancelled) return; const text = typeof raw === 'string' ? raw : String(raw || ''); const record = JSON.parse(text || '{}') as RdpRecord; const list = (record.frames || []).filter((f) => f.type === 'bitmap' && f.image); const sessionFrame = (record.frames || []).find((f) => f.type === 'session' && f.width && f.height); const sizeFrame = sessionFrame || list.find((f) => f.width && f.height); if (sizeFrame?.width && sizeFrame?.height) { setCanvasSize({ width: sizeFrame.width, height: sizeFrame.height }); } setFrames(list); setStatus(list.length ? '' : i18n.t('monitor.rdp.noFrames')); setPlaying(list.length > 0); setLoading(false); }) .catch(() => { if (!cancelled) { setStatus(i18n.t('monitor.rdp.loadFailed')); setLoading(false); } }); rdpSessionApi.logReplayView(sessionId).catch(() => {}); return () => { cancelled = true; }; }, [open, sessionId, mode]); useEffect(() => { if (!playing || mode !== 'replay') return; timerRef.current = window.setInterval(() => { setIndex((current) => { const next = current + 1; if (next >= playableFrames.length) { setPlaying(false); return current; } void drawReplay(next); return next; }); }, FRAME_MS); return () => { if (timerRef.current !== null) window.clearInterval(timerRef.current); timerRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [playing, mode, playableFrames.length]); useEffect(() => { if (mode !== 'replay' || playableFrames.length === 0) return; const seq = replaySeqRef.current + 1; replaySeqRef.current = seq; void drawReplay(index, true).then(() => { if (replaySeqRef.current !== seq) return; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [index, playableFrames.length, mode]); const displayHeight = fullscreen ? 'calc(100vh - 88px)' : '560px'; return ( {mode === 'live' ? i18n.t('system.rdpLiveAudit') : i18n.t('system.rdpHistoryReplay')} {i18n.t("system.rdpAuditDesc")}
{mode === 'live' ? i18n.t('system.rdpLiveAudit') : i18n.t('system.rdpHistoryReplayId', { id: sessionId || '' })}
{loading && } {status &&
{status}
}
{mode === 'replay' && (
{ setPlaying(false); setIndex(v[0] || 0); }} className="flex-1" /> {playableFrames.length ? index + 1 : 0} / {playableFrames.length}
)}
); }