diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index 8b92cac..d84dbdf 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -129,7 +129,7 @@ | ~~`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) | | ~~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 🟡 中优先级 diff --git a/ROADMAP.md b/ROADMAP.md index 07127c7..99c3932 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -487,6 +487,40 @@ services/api.ts (1324L) | Q13 | 前端巨型组件拆分(UnifiedQueryPanel 682L→529L,提取 constants + entry-mappers + 5 个 UI 子组件) | P0 | **done** | | Q14 | 前端巨型组件拆分(ServerManager 652L→442L,提取 ServerTableSection + server-export-utils) | 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 连接/帧绘制/鼠标键盘/剪贴板同步/文件下载/全屏切换全部正常) --- diff --git a/frontend/src/components/server/RdpDialog.tsx b/frontend/src/components/server/RdpDialog.tsx index d8bfddb..1e0c36b 100644 --- a/frontend/src/components/server/RdpDialog.tsx +++ b/frontend/src/components/server/RdpDialog.tsx @@ -10,99 +10,23 @@ import { cn } from "@/lib/utils"; import { getWsBase } from "@/utils/request"; import { useSessionSecurityStore, useUserStore } from "@/store"; 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 { 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; - 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), - }; -} +import { Loader2 } from "lucide-react"; +import { RdpDialogHeader } from "./rdp-dialog/RdpDialogHeader"; +import { + calcRdpViewport, + paintBitmap, + parseRawFrame, + readModifiers, +} from "./rdp-dialog/rdp-utils"; +import { + RDP_HEADER_HEIGHT, + type KeyModifierState, + type RdpDialogProps, + type RdpFrame, + type RdpRawFrame, + type RdpStats, +} from "./rdp-dialog/types"; export function RdpDialog({ open, @@ -118,7 +42,7 @@ export function RdpDialog({ const statsRef = useRef({ received: 0, drawn: 0, failed: 0 }); const pendingMouseMoveRef = useRef | null>(null); const mouseMoveTimerRef = useRef(null); - const keyModifierRef = useRef({ + const keyModifierRef = useRef({ ctrl: false, alt: false, shift: false, @@ -181,21 +105,10 @@ export function RdpDialog({ if (e.code === "MetaLeft" || e.code === "MetaRight") next.meta = down; }; - const currentModifiers = (e: KeyboardEvent) => { - 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) => { e.preventDefault(); patchKeyModifier(e, true); - const modifiers = currentModifiers(e); + const modifiers = readModifiers(e, keyModifierRef.current); const pasteCombo = (e.metaKey || e.ctrlKey) && e.code === "KeyV"; if (pasteCombo) { if (!clipboardEnabled) { @@ -218,7 +131,7 @@ export function RdpDialog({ const handleKeyUp = (e: KeyboardEvent) => { e.preventDefault(); if ((e.metaKey || e.ctrlKey) && e.code === "KeyV") return; - const modifiers = currentModifiers(e); + const modifiers = readModifiers(e, keyModifierRef.current); patchKeyModifier(e, false); sendEvent({ type: "key", @@ -244,72 +157,8 @@ export function RdpDialog({ 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((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 => { - 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); + const frame: RdpRawFrame | null = await parseRawFrame(data); if (!frame) return; setStatus(""); const canvas = canvasRef.current; @@ -488,81 +337,32 @@ export function RdpDialog({ {t("server.rdpDialog.graphicSession")} -
-
-
- - {serverName} - - ({serverIp}) -
-
- {serverId && ( - - - - - - { - sendEvent({ type: "clipboard", text }); - setStatus(t("server.rdpDialog.synced")); - }} - onRequestRemoteCopy={requestRemoteCopy} - onUploadVirtualFile={(name, text) => { - const sent = sendEvent({ - type: "rdp_file_upload", - name, - text, - }); - if (!sent) return false; - setStatus(t("server.rdpDialog.uploadStaged", { name })); - return true; - }} - /> - - - )} - - -
-
+ setFullscreen(!fullscreen)} + onClose={() => onOpenChange(false)} + remoteClipboard={remoteClipboard} + onSyncToRemote={(text) => { + sendEvent({ type: "clipboard", text }); + setStatus(t("server.rdpDialog.synced")); + }} + onRequestRemoteCopy={requestRemoteCopy} + onUploadVirtualFile={(name, text) => { + const sent = sendEvent({ + type: "rdp_file_upload", + name, + text, + }); + if (!sent) return false; + setStatus(t("server.rdpDialog.uploadStaged", { name })); + return true; + }} + />
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 ( +
+
+
+ + {serverName} + + ({serverIp}) +
+
+ + + + + + + + + + +
+
+ ); +} diff --git a/frontend/src/components/server/rdp-dialog/rdp-utils.ts b/frontend/src/components/server/rdp-dialog/rdp-utils.ts new file mode 100644 index 0000000..b2aa17c --- /dev/null +++ b/frontend/src/components/server/rdp-dialog/rdp-utils.ts @@ -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 { + 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((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 { + 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, + 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, + }; +} diff --git a/frontend/src/components/server/rdp-dialog/types.ts b/frontend/src/components/server/rdp-dialog/types.ts new file mode 100644 index 0000000..26915f3 --- /dev/null +++ b/frontend/src/components/server/rdp-dialog/types.ts @@ -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;