refactor(quality): Q16 前端巨型组件拆分 — RdpDialog 642L→442L (-31%), 提取 types + rdpp-utils + RdpDialogHeader 3 个独立文件, 清理 RDP_DIALOG_VERTICAL_GAP 死代码, 零逻辑变更, ESLint errors=0, tsc -b=0, npm build✓, 17/17 测试通过
This commit is contained in:
1 parent
1f3b419d62
commit
4905724338
6 files changed
+357
-230
No files matched your search
@@ -129,7 +129,7 @@
|
|||||||
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
||||||
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
||||||
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
||||||
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q15 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%),累计提取 45 个独立组件文件** |
|
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q16 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + **RdpDialog 642L→442L (-31%)**,累计提取 48 个独立组件文件** |
|
||||||
|
|
||||||
### 3.2 🟡 中优先级
|
### 3.2 🟡 中优先级
|
||||||
|
|
||||||
|
|||||||
+34
@@ -487,6 +487,40 @@ services/api.ts (1324L)
|
|||||||
| Q13 | 前端巨型组件拆分(UnifiedQueryPanel 682L→529L,提取 constants + entry-mappers + 5 个 UI 子组件) | P0 | **done** |
|
| Q13 | 前端巨型组件拆分(UnifiedQueryPanel 682L→529L,提取 constants + entry-mappers + 5 个 UI 子组件) | P0 | **done** |
|
||||||
| Q14 | 前端巨型组件拆分(ServerManager 652L→442L,提取 ServerTableSection + server-export-utils) | P0 | **done** |
|
| Q14 | 前端巨型组件拆分(ServerManager 652L→442L,提取 ServerTableSection + server-export-utils) | P0 | **done** |
|
||||||
| Q15 | 前端巨型组件拆分(PipelineEditor 676L→406L,提取 types + TaskCard + TaskEditorPanel + PipelineLogDialog + RunsHistoryTab + PipelineMembersTab) | P0 | **done** |
|
| Q15 | 前端巨型组件拆分(PipelineEditor 676L→406L,提取 types + TaskCard + TaskEditorPanel + PipelineLogDialog + RunsHistoryTab + PipelineMembersTab) | P0 | **done** |
|
||||||
|
| Q16 | 前端巨型组件拆分(RdpDialog 642L→442L,提取 types + rdpp-utils + RdpDialogHeader) | P0 | **done** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q16: 前端巨型组件拆分 — RdpDialog [done]
|
||||||
|
|
||||||
|
**问题**: `RdpDialog.tsx`(642 行)是 Q15 拆分后项目最大的核心业务组件之一(排除 UI 库 sidebar/data-table),包含 RDP 远程桌面弹窗的全部逻辑:接口定义 + 分辨率常量、视口计算纯函数(clampRdpSize/calcRdpViewport)、位图绘制/二进制帧解析纯函数(paintBitmap/parseRawFrame)、修饰键状态工具、WebSocket 连接生命周期、鼠标/键盘事件处理、帧绘制(bitmap/rawFrame 双通道)、以及内联的顶部标题栏 JSX(连接状态指示 + 工具弹窗 + 全屏/关闭按钮)。单文件过大导致维护困难。
|
||||||
|
|
||||||
|
**已完成 (2026-07-08)**:
|
||||||
|
|
||||||
|
将类型/常量、纯函数、标题栏组件提取为 `rdp-dialog/` 子目录下的独立文件:
|
||||||
|
|
||||||
|
| 新文件 | 行数 | 职责 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `types.ts` | 55 | RdpDialogProps/RdpFrame/RdpStats/RdpRawFrame/KeyModifierState 接口 + 分辨率常量(RDP_DEFAULT_WIDTH 等 9 个) |
|
||||||
|
| `rdp-utils.ts` | 133 | clampRdpSize/calcRdpViewport(视口计算)+ paintBitmap/parseRawFrame(位图绘制/二进制帧解析)+ readModifiers(修饰键提取)纯函数 |
|
||||||
|
| `RdpDialogHeader.tsx` | 105 | 顶部标题栏(连接状态指示灯 + 服务器名/IP + 工具 Popover + 全屏/关闭按钮),接收 13 个 props 的受控展示组件 |
|
||||||
|
|
||||||
|
主文件 `RdpDialog.tsx` 从 **642 行降至 442 行**(-31%),仅保留:refs(canvas/ws/paintQueue/stats/mouseMove/keyModifier)、状态管理(connected/fullscreen/status/hasFrame/canvasSize/displaySize/toolsOpen/remoteClipboard)、sendEvent/flushMouseMove/sendMouseMove、patchKeyModifier/handleKeyDown/handleKeyUp/requestRemoteCopy、drawRawFrame/drawFrame(使用 refs + state)、WebSocket 生命周期 effect、canvasPoint/canvas 事件 JSX、loading/error 覆盖层。
|
||||||
|
|
||||||
|
**提取原则**: types 为纯数据/类型定义(零 React 依赖);rdpp-utils 为纯函数(接收参数返回结果,零 React 依赖,`paintBitmap` 接收 ctx + frame,`parseRawFrame` 接收 Blob/ArrayBuffer);RdpDialogHeader 为受控展示组件(接收全部状态 + 回调 props,内部仅 useTranslation)。零逻辑变更。清理了 `RDP_DIALOG_VERTICAL_GAP` 死代码(calcRdpViewport 的非全屏分支提前 return,该常量在后续分支不可达)。
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [x] RdpDialog.tsx 从 642 行降至 **442 行**(-31%)
|
||||||
|
- [x] 3 个提取的文件全部 < 135 行(最大 rdpp-utils 133 行)
|
||||||
|
- [x] `tsc -b` 零错误
|
||||||
|
- [x] `npm run build` 零错误(✓ built in 15.78s)
|
||||||
|
- [x] ESLint errors 保持 **0**(清理 1 个 unused import: RDP_DIALOG_VERTICAL_GAP)
|
||||||
|
- [x] ESLint warnings 保持 **64**(无新增)
|
||||||
|
- [x] 全局 ESLint any 保持 **0**
|
||||||
|
- [x] `go build` 零错误
|
||||||
|
- [x] 17/17 集成回归测试通过(零回归)
|
||||||
|
- [x] Docker 部署 healthy
|
||||||
|
- [x] RDP 远程桌面弹窗功能不变(WebSocket 连接/帧绘制/鼠标键盘/剪贴板同步/文件下载/全屏切换全部正常)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,99 +10,23 @@ import { cn } from "@/lib/utils";
|
|||||||
import { getWsBase } from "@/utils/request";
|
import { getWsBase } from "@/utils/request";
|
||||||
import { useSessionSecurityStore, useUserStore } from "@/store";
|
import { useSessionSecurityStore, useUserStore } from "@/store";
|
||||||
import { copyToClipboard, readFromClipboard } from "@/utils/clipboard";
|
import { copyToClipboard, readFromClipboard } from "@/utils/clipboard";
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverAnchor,
|
|
||||||
PopoverContent,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { RdpToolsPanel } from "./RdpToolsPanel";
|
|
||||||
import { SessionWatermark } from "@/components/common/SessionWatermark";
|
import { SessionWatermark } from "@/components/common/SessionWatermark";
|
||||||
import { Loader2, Maximize2, Minimize2, Wrench, X } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { RdpDialogHeader } from "./rdp-dialog/RdpDialogHeader";
|
||||||
interface RdpDialogProps {
|
import {
|
||||||
open: boolean;
|
calcRdpViewport,
|
||||||
onOpenChange: (open: boolean) => void;
|
paintBitmap,
|
||||||
serverId: number | null;
|
parseRawFrame,
|
||||||
serverName: string;
|
readModifiers,
|
||||||
serverIp: string;
|
} from "./rdp-dialog/rdp-utils";
|
||||||
}
|
import {
|
||||||
|
RDP_HEADER_HEIGHT,
|
||||||
interface RdpFrame {
|
type KeyModifierState,
|
||||||
type: string;
|
type RdpDialogProps,
|
||||||
x?: number;
|
type RdpFrame,
|
||||||
y?: number;
|
type RdpRawFrame,
|
||||||
width?: number;
|
type RdpStats,
|
||||||
height?: number;
|
} from "./rdp-dialog/types";
|
||||||
image?: string;
|
|
||||||
message?: string;
|
|
||||||
text?: string;
|
|
||||||
name?: string;
|
|
||||||
url?: string;
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RdpStats {
|
|
||||||
received: number;
|
|
||||||
drawn: number;
|
|
||||||
failed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RDP_DEFAULT_WIDTH = 900;
|
|
||||||
const RDP_DEFAULT_HEIGHT = 620;
|
|
||||||
const RDP_MIN_WIDTH = 720;
|
|
||||||
const RDP_MIN_HEIGHT = 520;
|
|
||||||
const RDP_MAX_WIDTH = 1920;
|
|
||||||
const RDP_MAX_HEIGHT = 1200;
|
|
||||||
const RDP_BINARY_HEADER = 20;
|
|
||||||
const RDP_HEADER_HEIGHT = 41;
|
|
||||||
const RDP_DIALOG_VERTICAL_GAP = 96;
|
|
||||||
|
|
||||||
interface RdpRawFrame {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
rgba: Uint8ClampedArray;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampRdpSize(value: number, min: number, max: number) {
|
|
||||||
const clamped = Math.max(min, Math.min(max, value));
|
|
||||||
return clamped % 2 === 0 ? clamped : clamped - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calcRdpViewport(fullscreen: boolean) {
|
|
||||||
if (!fullscreen) {
|
|
||||||
return {
|
|
||||||
// 13 寸屏默认保留外层操作空间,最大化时再提升远端分辨率。 // Logan.Li
|
|
||||||
width: Math.min(
|
|
||||||
RDP_DEFAULT_WIDTH,
|
|
||||||
clampRdpSize(
|
|
||||||
Math.floor(window.innerWidth * 0.78),
|
|
||||||
RDP_MIN_WIDTH,
|
|
||||||
RDP_DEFAULT_WIDTH,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
height: Math.min(
|
|
||||||
RDP_DEFAULT_HEIGHT,
|
|
||||||
clampRdpSize(
|
|
||||||
Math.floor(window.innerHeight - 180),
|
|
||||||
RDP_MIN_HEIGHT,
|
|
||||||
RDP_DEFAULT_HEIGHT,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const maxWidth = fullscreen
|
|
||||||
? window.innerWidth
|
|
||||||
: Math.min(window.innerWidth * 0.94, RDP_MAX_WIDTH);
|
|
||||||
const maxHeight = fullscreen
|
|
||||||
? window.innerHeight - RDP_HEADER_HEIGHT
|
|
||||||
: window.innerHeight - RDP_DIALOG_VERTICAL_GAP;
|
|
||||||
return {
|
|
||||||
width: clampRdpSize(Math.floor(maxWidth), RDP_MIN_WIDTH, RDP_MAX_WIDTH),
|
|
||||||
height: clampRdpSize(Math.floor(maxHeight), RDP_MIN_HEIGHT, RDP_MAX_HEIGHT),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RdpDialog({
|
export function RdpDialog({
|
||||||
open,
|
open,
|
||||||
@@ -118,7 +42,7 @@ export function RdpDialog({
|
|||||||
const statsRef = useRef<RdpStats>({ received: 0, drawn: 0, failed: 0 });
|
const statsRef = useRef<RdpStats>({ received: 0, drawn: 0, failed: 0 });
|
||||||
const pendingMouseMoveRef = useRef<Record<string, unknown> | null>(null);
|
const pendingMouseMoveRef = useRef<Record<string, unknown> | null>(null);
|
||||||
const mouseMoveTimerRef = useRef<number | null>(null);
|
const mouseMoveTimerRef = useRef<number | null>(null);
|
||||||
const keyModifierRef = useRef({
|
const keyModifierRef = useRef<KeyModifierState>({
|
||||||
ctrl: false,
|
ctrl: false,
|
||||||
alt: false,
|
alt: false,
|
||||||
shift: false,
|
shift: false,
|
||||||
@@ -181,21 +105,10 @@ export function RdpDialog({
|
|||||||
if (e.code === "MetaLeft" || e.code === "MetaRight") next.meta = down;
|
if (e.code === "MetaLeft" || e.code === "MetaRight") next.meta = down;
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentModifiers = (e: KeyboardEvent<HTMLCanvasElement>) => {
|
|
||||||
const state = keyModifierRef.current;
|
|
||||||
// keyup 事件经常丢失 ctrlKey/metaKey,使用本地状态保证组合键释放也能发到远端。 // Logan.Li
|
|
||||||
return {
|
|
||||||
ctrl: state.ctrl || e.ctrlKey,
|
|
||||||
alt: state.alt || e.altKey,
|
|
||||||
shift: state.shift || e.shiftKey,
|
|
||||||
meta: state.meta || e.metaKey,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = async (e: KeyboardEvent<HTMLCanvasElement>) => {
|
const handleKeyDown = async (e: KeyboardEvent<HTMLCanvasElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
patchKeyModifier(e, true);
|
patchKeyModifier(e, true);
|
||||||
const modifiers = currentModifiers(e);
|
const modifiers = readModifiers(e, keyModifierRef.current);
|
||||||
const pasteCombo = (e.metaKey || e.ctrlKey) && e.code === "KeyV";
|
const pasteCombo = (e.metaKey || e.ctrlKey) && e.code === "KeyV";
|
||||||
if (pasteCombo) {
|
if (pasteCombo) {
|
||||||
if (!clipboardEnabled) {
|
if (!clipboardEnabled) {
|
||||||
@@ -218,7 +131,7 @@ export function RdpDialog({
|
|||||||
const handleKeyUp = (e: KeyboardEvent<HTMLCanvasElement>) => {
|
const handleKeyUp = (e: KeyboardEvent<HTMLCanvasElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if ((e.metaKey || e.ctrlKey) && e.code === "KeyV") return;
|
if ((e.metaKey || e.ctrlKey) && e.code === "KeyV") return;
|
||||||
const modifiers = currentModifiers(e);
|
const modifiers = readModifiers(e, keyModifierRef.current);
|
||||||
patchKeyModifier(e, false);
|
patchKeyModifier(e, false);
|
||||||
sendEvent({
|
sendEvent({
|
||||||
type: "key",
|
type: "key",
|
||||||
@@ -244,72 +157,8 @@ export function RdpDialog({
|
|||||||
statsRef.current = { ...statsRef.current, ...patch };
|
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(t("server.rdpDialog.bitmapDecodeFailed")));
|
|
||||||
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 drawRawFrame = async (data: Blob | ArrayBuffer) => {
|
||||||
const frame = await parseRawFrame(data);
|
const frame: RdpRawFrame | null = await parseRawFrame(data);
|
||||||
if (!frame) return;
|
if (!frame) return;
|
||||||
setStatus("");
|
setStatus("");
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -488,41 +337,15 @@ export function RdpDialog({
|
|||||||
<DialogDescription className="sr-only">
|
<DialogDescription className="sr-only">
|
||||||
{t("server.rdpDialog.graphicSession")}
|
{t("server.rdpDialog.graphicSession")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
|
<RdpDialogHeader
|
||||||
<div className="flex items-center gap-2 text-sm">
|
serverName={serverName}
|
||||||
<div
|
serverIp={serverIp}
|
||||||
className={cn(
|
connected={connected}
|
||||||
"w-2 h-2 rounded-full",
|
fullscreen={fullscreen}
|
||||||
connected ? "bg-emerald-400" : "bg-red-400",
|
toolsOpen={toolsOpen}
|
||||||
)}
|
onToolsOpenChange={setToolsOpen}
|
||||||
/>
|
onToggleFullscreen={() => setFullscreen(!fullscreen)}
|
||||||
<span className="text-[var(--dg-text-primary)] font-medium">
|
onClose={() => onOpenChange(false)}
|
||||||
{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={t("server.rdpDialog.tools")}
|
|
||||||
>
|
|
||||||
<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
|
|
||||||
remoteClipboard={remoteClipboard}
|
remoteClipboard={remoteClipboard}
|
||||||
onSyncToRemote={(text) => {
|
onSyncToRemote={(text) => {
|
||||||
sendEvent({ type: "clipboard", text });
|
sendEvent({ type: "clipboard", text });
|
||||||
@@ -540,29 +363,6 @@ export function RdpDialog({
|
|||||||
return true;
|
return true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</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
|
<div
|
||||||
className="relative bg-black flex items-center justify-center"
|
className="relative bg-black flex items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// RDP 远程桌面弹窗 — 顶部标题栏(连接状态 + 工具弹窗 + 全屏/关闭)
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverContent,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { RdpToolsPanel } from "../RdpToolsPanel";
|
||||||
|
import { Maximize2, Minimize2, Wrench, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface RdpDialogHeaderProps {
|
||||||
|
serverName: string;
|
||||||
|
serverIp: string;
|
||||||
|
connected: boolean;
|
||||||
|
fullscreen: boolean;
|
||||||
|
toolsOpen: boolean;
|
||||||
|
onToolsOpenChange: (open: boolean) => void;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
remoteClipboard: string;
|
||||||
|
onSyncToRemote: (text: string) => void;
|
||||||
|
onRequestRemoteCopy: () => void;
|
||||||
|
onUploadVirtualFile: (name: string, text: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RdpDialogHeader({
|
||||||
|
serverName,
|
||||||
|
serverIp,
|
||||||
|
connected,
|
||||||
|
fullscreen,
|
||||||
|
toolsOpen,
|
||||||
|
onToolsOpenChange,
|
||||||
|
onToggleFullscreen,
|
||||||
|
onClose,
|
||||||
|
remoteClipboard,
|
||||||
|
onSyncToRemote,
|
||||||
|
onRequestRemoteCopy,
|
||||||
|
onUploadVirtualFile,
|
||||||
|
}: RdpDialogHeaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<Popover open={toolsOpen} onOpenChange={onToolsOpenChange}>
|
||||||
|
<PopoverAnchor asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToolsOpenChange(!toolsOpen)}
|
||||||
|
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={t("server.rdpDialog.tools")}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
remoteClipboard={remoteClipboard}
|
||||||
|
onSyncToRemote={onSyncToRemote}
|
||||||
|
onRequestRemoteCopy={onRequestRemoteCopy}
|
||||||
|
onUploadVirtualFile={onUploadVirtualFile}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
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={onClose}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// RDP 远程桌面弹窗 — 纯函数(视口计算 + 位图绘制 + 二进制帧解析)
|
||||||
|
import type { KeyboardEvent } from "react";
|
||||||
|
import type {
|
||||||
|
KeyModifierState,
|
||||||
|
RdpFrame,
|
||||||
|
RdpRawFrame,
|
||||||
|
} from "./types";
|
||||||
|
import {
|
||||||
|
RDP_BINARY_HEADER,
|
||||||
|
RDP_DEFAULT_HEIGHT,
|
||||||
|
RDP_DEFAULT_WIDTH,
|
||||||
|
RDP_HEADER_HEIGHT,
|
||||||
|
RDP_MAX_HEIGHT,
|
||||||
|
RDP_MAX_WIDTH,
|
||||||
|
RDP_MIN_HEIGHT,
|
||||||
|
RDP_MIN_WIDTH,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/** 将分辨率约束到 [min, max] 且为偶数(RDP 协议要求偶数宽高)。 */
|
||||||
|
export function clampRdpSize(value: number, min: number, max: number) {
|
||||||
|
const clamped = Math.max(min, Math.min(max, value));
|
||||||
|
return clamped % 2 === 0 ? clamped : clamped - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据窗口大小 + 全屏状态计算远程桌面最佳分辨率。 */
|
||||||
|
export function calcRdpViewport(fullscreen: boolean) {
|
||||||
|
if (!fullscreen) {
|
||||||
|
// 13 寸屏默认保留外层操作空间,最大化时再提升远端分辨率。 // Logan.Li
|
||||||
|
return {
|
||||||
|
width: Math.min(
|
||||||
|
RDP_DEFAULT_WIDTH,
|
||||||
|
clampRdpSize(
|
||||||
|
Math.floor(window.innerWidth * 0.78),
|
||||||
|
RDP_MIN_WIDTH,
|
||||||
|
RDP_DEFAULT_WIDTH,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
height: Math.min(
|
||||||
|
RDP_DEFAULT_HEIGHT,
|
||||||
|
clampRdpSize(
|
||||||
|
Math.floor(window.innerHeight - 180),
|
||||||
|
RDP_MIN_HEIGHT,
|
||||||
|
RDP_DEFAULT_HEIGHT,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const maxWidth = window.innerWidth;
|
||||||
|
const maxHeight = window.innerHeight - RDP_HEADER_HEIGHT;
|
||||||
|
return {
|
||||||
|
width: clampRdpSize(Math.floor(maxWidth), RDP_MIN_WIDTH, RDP_MAX_WIDTH),
|
||||||
|
height: clampRdpSize(
|
||||||
|
Math.floor(maxHeight),
|
||||||
|
RDP_MIN_HEIGHT,
|
||||||
|
RDP_MAX_HEIGHT,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 dataURL/bitmap 绘制到 canvas 上下文(先试 createImageBitmap,失败回退 Image.onload)。 */
|
||||||
|
export async function paintBitmap(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
frame: RdpFrame,
|
||||||
|
): Promise<void> {
|
||||||
|
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("bitmap decode failed"));
|
||||||
|
img.src = frame.image || "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 RDP 二进制帧(magic=RDPR,4 字节 + x/y/width/height 各 4 字节 + RGBA 像素)。 */
|
||||||
|
export async function parseRawFrame(
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 keydown/keyup 事件提取修饰键状态(keyup 经常丢失 ctrlKey/metaKey,用 ref 补全)。 */
|
||||||
|
export function readModifiers(
|
||||||
|
e: KeyboardEvent<HTMLElement>,
|
||||||
|
fallback: KeyModifierState,
|
||||||
|
): KeyModifierState {
|
||||||
|
// keyup 事件经常丢失 ctrlKey/metaKey,使用本地状态保证组合键释放也能发到远端。 // Logan.Li
|
||||||
|
return {
|
||||||
|
ctrl: fallback.ctrl || e.ctrlKey,
|
||||||
|
alt: fallback.alt || e.altKey,
|
||||||
|
shift: fallback.shift || e.shiftKey,
|
||||||
|
meta: fallback.meta || e.metaKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// RDP 远程桌面弹窗 — 类型定义 + 常量
|
||||||
|
|
||||||
|
export interface RdpDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
serverId: number | null;
|
||||||
|
serverName: string;
|
||||||
|
serverIp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RdpFrame {
|
||||||
|
type: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
image?: string;
|
||||||
|
message?: string;
|
||||||
|
text?: string;
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RdpStats {
|
||||||
|
received: number;
|
||||||
|
drawn: number;
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RdpRawFrame {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rgba: Uint8ClampedArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyModifierState {
|
||||||
|
ctrl: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
meta: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分辨率常量
|
||||||
|
export const RDP_DEFAULT_WIDTH = 900;
|
||||||
|
export const RDP_DEFAULT_HEIGHT = 620;
|
||||||
|
export const RDP_MIN_WIDTH = 720;
|
||||||
|
export const RDP_MIN_HEIGHT = 520;
|
||||||
|
export const RDP_MAX_WIDTH = 1920;
|
||||||
|
export const RDP_MAX_HEIGHT = 1200;
|
||||||
|
export const RDP_BINARY_HEADER = 20;
|
||||||
|
export const RDP_HEADER_HEIGHT = 41;
|
||||||
|
export const RDP_DIALOG_VERTICAL_GAP = 96;
|
||||||
Reference in new issue
Block a user