From 2b2122918c83345b6612bb580e15e858d93463c5 Mon Sep 17 00:00:00 2001 From: mxrain Date: Wed, 7 Jan 2026 20:52:33 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E8=80=85=E8=AE=BE=E7=BD=AE=E9=A1=B5=E9=9D=A2=E5=92=8C?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E8=A7=86=E5=9B=BE=E8=B0=83=E8=AF=95=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 42 ++- src/components/dev/ComponentDebugOverlay.tsx | 310 ++++++++++++++++++ src/components/dev/index.ts | 1 + src/components/settings/DeveloperSettings.tsx | 50 +++ src/components/settings/SettingsPage.tsx | 5 +- src/components/settings/index.ts | 1 + src/contexts/ComponentDebugContext.tsx | 75 +++++ 7 files changed, 465 insertions(+), 19 deletions(-) create mode 100644 src/components/dev/ComponentDebugOverlay.tsx create mode 100644 src/components/dev/index.ts create mode 100644 src/components/settings/DeveloperSettings.tsx create mode 100644 src/contexts/ComponentDebugContext.tsx diff --git a/src/App.tsx b/src/App.tsx index 7e5202642..20276be8f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,8 @@ import { ConnectConfirmDialog } from "./components/connect"; import { showRegistryLoadError } from "./lib/utils/connectError"; import { useDeepLink } from "./hooks/useDeepLink"; import { useRelayRegistry } from "./hooks/useRelayRegistry"; +import { ComponentDebugProvider } from "./contexts/ComponentDebugContext"; +import { ComponentDebugOverlay } from "./components/dev"; /** * 页面类型定义 @@ -229,24 +231,28 @@ function App() { // 4. 正常主界面 return ( - - - {renderPage()} - {/* ProxyCast Connect 确认弹窗 */} - {/* _Requirements: 5.2_ */} - - + + + + {renderPage()} + {/* ProxyCast Connect 确认弹窗 */} + {/* _Requirements: 5.2_ */} + + {/* 组件视图调试覆盖层 */} + + + ); } diff --git a/src/components/dev/ComponentDebugOverlay.tsx b/src/components/dev/ComponentDebugOverlay.tsx new file mode 100644 index 000000000..c433d3ccb --- /dev/null +++ b/src/components/dev/ComponentDebugOverlay.tsx @@ -0,0 +1,310 @@ +/** + * @file ComponentDebugOverlay.tsx + * @description 组件视图调试覆盖层 - Alt+悬浮显示轮廓,Alt+点击显示组件信息 + */ +import { useEffect, useCallback, useState } from "react"; +import { useComponentDebug, ComponentInfo } from "@/contexts/ComponentDebugContext"; +import { X, Copy, Check, Component, FileCode, Layers, Hash } from "lucide-react"; + +/** + * 从 React Fiber 节点获取组件信息 + */ +function getReactFiberInfo(element: HTMLElement): Partial | null { + const fiberKey = Object.keys(element).find( + (key) => key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$") + ); + + if (!fiberKey) return null; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let fiber = (element as any)[fiberKey]; + if (!fiber) return null; + + let depth = 0; + while (fiber) { + const type = fiber.type; + if (type && typeof type === "function") { + const name = type.displayName || type.name || "Anonymous"; + if (!name.startsWith("_") && name !== "Anonymous") { + let filePath = "未知路径"; + if (fiber._debugSource) { + filePath = `${fiber._debugSource.fileName}:${fiber._debugSource.lineNumber}`; + } else if (type._source) { + filePath = `${type._source.fileName}:${type._source.lineNumber}`; + } + + const props = fiber.memoizedProps || {}; + const safeProps: Record = {}; + for (const key of Object.keys(props)) { + const value = props[key]; + if (typeof value === "function") { + safeProps[key] = "[Function]"; + } else if (typeof value === "object" && value !== null) { + if (Array.isArray(value)) { + safeProps[key] = `[Array(${value.length})]`; + } else if (value.$$typeof) { + safeProps[key] = "[ReactElement]"; + } else { + safeProps[key] = "[Object]"; + } + } else { + safeProps[key] = value; + } + } + + return { name, filePath, props: safeProps, depth }; + } + } + fiber = fiber.return; + depth++; + } + return null; +} + +/** 组件信息弹窗 */ +function ComponentInfoPopup() { + const { componentInfo, hideComponentInfo } = useComponentDebug(); + const [copiedField, setCopiedField] = useState(null); + + if (!componentInfo) return null; + + const handleCopy = async (text: string, field: string) => { + await navigator.clipboard.writeText(text); + setCopiedField(field); + setTimeout(() => setCopiedField(null), 2000); + }; + + const propsEntries = Object.entries(componentInfo.props || {}).filter( + ([key]) => key !== "children" + ); + + return ( +
e.stopPropagation()} + > + {/* 标题栏 */} +
+
+ + 组件信息 +
+ +
+ + {/* 内容区域 */} +
+ {/* 组件名称 */} +
+ +
+
组件名称
+
+ {componentInfo.name} + +
+
+
+ + {/* 文件路径 */} +
+ +
+
文件路径
+
+ {componentInfo.filePath} + +
+
+
+ + {/* HTML 标签 */} +
+ +
+
DOM 元素
+ <{componentInfo.tagName.toLowerCase()}> +
+
+ + {/* 组件层级 */} +
+ +
+
组件层级
+ 第 {componentInfo.depth} 层 +
+
+ + {/* Props */} + {propsEntries.length > 0 && ( +
+
Props
+
+
+ {propsEntries.slice(0, 10).map(([key, value]) => ( +
+ {key}: + + {typeof value === "string" ? `"${value}"` : String(value)} + +
+ ))} + {propsEntries.length > 10 && ( +
... 还有 {propsEntries.length - 10} 个属性
+ )} +
+
+
+ )} +
+ + {/* 底部提示 */} +
+

提示: 按 Esc 或点击其他区域关闭

+
+
+ ); +} + +/** 调试交互处理 */ +function DebugInteractionHandler() { + const { enabled, showComponentInfo, hideComponentInfo } = useComponentDebug(); + const [altPressed, setAltPressed] = useState(false); + const [hoveredElement, setHoveredElement] = useState(null); + + // 监听 Alt 键 + useEffect(() => { + if (!enabled) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Alt") { + setAltPressed(true); + } + if (e.key === "Escape") { + hideComponentInfo(); + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === "Alt") { + setAltPressed(false); + setHoveredElement(null); + } + }; + + // 窗口失焦时重置状态 + const handleBlur = () => { + setAltPressed(false); + setHoveredElement(null); + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + }; + }, [enabled, hideComponentInfo]); + + // 监听鼠标移动(仅在 Alt 按下时) + useEffect(() => { + if (!enabled || !altPressed) { + setHoveredElement(null); + return; + } + + const handleMouseMove = (e: MouseEvent) => { + const target = e.target as HTMLElement; + // 忽略弹窗内的元素 + if (target.closest(".component-debug-popup")) return; + setHoveredElement(target); + }; + + document.addEventListener("mousemove", handleMouseMove); + return () => document.removeEventListener("mousemove", handleMouseMove); + }, [enabled, altPressed]); + + // 监听点击(仅在 Alt 按下时) + useEffect(() => { + if (!enabled) return; + + const handleClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + + // 点击弹窗外部关闭弹窗 + if (!target.closest(".component-debug-popup")) { + if (!e.altKey) { + hideComponentInfo(); + return; + } + + // Alt + 点击显示组件信息 + e.preventDefault(); + e.stopPropagation(); + + const fiberInfo = getReactFiberInfo(target); + showComponentInfo({ + name: fiberInfo?.name || "DOM Element", + filePath: fiberInfo?.filePath || "非 React 组件", + props: fiberInfo?.props || {}, + depth: fiberInfo?.depth || 0, + tagName: target.tagName, + x: e.clientX + 10, + y: e.clientY + 10, + }); + } + }; + + document.addEventListener("click", handleClick, true); + return () => document.removeEventListener("click", handleClick, true); + }, [enabled, showComponentInfo, hideComponentInfo]); + + // 渲染悬浮高亮框 + if (!altPressed || !hoveredElement) return null; + + const rect = hoveredElement.getBoundingClientRect(); + + return ( +
+ ); +} + +export function ComponentDebugOverlay() { + const { enabled } = useComponentDebug(); + + if (!enabled) return null; + + return ( + <> + +
+ +
+ + ); +} diff --git a/src/components/dev/index.ts b/src/components/dev/index.ts new file mode 100644 index 000000000..02604df1c --- /dev/null +++ b/src/components/dev/index.ts @@ -0,0 +1 @@ +export { ComponentDebugOverlay } from "./ComponentDebugOverlay"; diff --git a/src/components/settings/DeveloperSettings.tsx b/src/components/settings/DeveloperSettings.tsx new file mode 100644 index 000000000..8da4d5a94 --- /dev/null +++ b/src/components/settings/DeveloperSettings.tsx @@ -0,0 +1,50 @@ +/** + * @file DeveloperSettings.tsx + * @description 开发者设置页面 - 组件视图调试等开发工具 + */ +import { Code2, Eye } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { useComponentDebug } from "@/contexts/ComponentDebugContext"; + +export function DeveloperSettings() { + const { enabled, setEnabled } = useComponentDebug(); + + return ( +
+ {/* 标题 */} +
+ +

开发者工具

+
+ + {/* 组件视图调试 */} +
+
+
+
+ +
+
+

组件视图调试

+

+ 显示组件轮廓,Alt+点击查看组件信息 +

+
+
+ +
+ + {enabled && ( +
+

使用说明:

+
    +
  • 所有标记的组件会显示蓝色虚线轮廓
  • +
  • 鼠标悬浮时轮廓高亮显示
  • +
  • 按住 Alt 键点击组件可查看名称和文件路径
  • +
+
+ )} +
+
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 5bce0e1e9..2e80a7c30 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -7,14 +7,16 @@ import { TlsSettings } from "./TlsSettings"; import { QuotaSettings } from "./QuotaSettings"; import { RemoteManagementSettings } from "./RemoteManagementSettings"; import { ExtensionsSettings } from "./ExtensionsSettings"; +import { DeveloperSettings } from "./DeveloperSettings"; -type SettingsTab = "general" | "security" | "advanced" | "extensions" | "about"; +type SettingsTab = "general" | "security" | "advanced" | "extensions" | "developer" | "about"; const tabs: { id: SettingsTab; label: string; experimental?: boolean }[] = [ { id: "general", label: "通用" }, { id: "security", label: "安全" }, { id: "advanced", label: "高级" }, { id: "extensions", label: "扩展", experimental: true }, + { id: "developer", label: "开发者" }, { id: "about", label: "关于" }, ]; @@ -68,6 +70,7 @@ export function SettingsPage() {
)} {activeTab === "extensions" && } + {activeTab === "developer" && } {activeTab === "about" && } diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index 375c40edf..78805f052 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -6,3 +6,4 @@ export { AboutSection } from "./AboutSection"; export { TlsSettings } from "./TlsSettings"; export { QuotaSettings } from "./QuotaSettings"; export { RemoteManagementSettings } from "./RemoteManagementSettings"; +export { DeveloperSettings } from "./DeveloperSettings"; diff --git a/src/contexts/ComponentDebugContext.tsx b/src/contexts/ComponentDebugContext.tsx new file mode 100644 index 000000000..ddc7e9ec0 --- /dev/null +++ b/src/contexts/ComponentDebugContext.tsx @@ -0,0 +1,75 @@ +/** + * @file ComponentDebugContext.tsx + * @description 组件视图调试上下文 - 提供全局组件轮廓显示和信息查看功能 + */ +import { createContext, useContext, useState, useCallback, ReactNode } from "react"; + +export interface ComponentInfo { + name: string; + filePath: string; + props: Record; + depth: number; + tagName: string; + x: number; + y: number; +} + +interface ComponentDebugContextType { + /** 是否启用组件视图调试 */ + enabled: boolean; + /** 切换调试模式 */ + setEnabled: (enabled: boolean) => void; + /** 当前显示的组件信息弹窗 */ + componentInfo: ComponentInfo | null; + /** 显示组件信息弹窗 */ + showComponentInfo: (info: ComponentInfo) => void; + /** 隐藏组件信息弹窗 */ + hideComponentInfo: () => void; +} + +const ComponentDebugContext = createContext(null); + +const STORAGE_KEY = "component-debug-enabled"; + +export function ComponentDebugProvider({ children }: { children: ReactNode }) { + const [enabled, setEnabledState] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY); + return stored === "true"; + }); + const [componentInfo, setComponentInfo] = useState(null); + + const setEnabled = useCallback((value: boolean) => { + setEnabledState(value); + localStorage.setItem(STORAGE_KEY, String(value)); + }, []); + + const showComponentInfo = useCallback((info: ComponentInfo) => { + setComponentInfo(info); + }, []); + + const hideComponentInfo = useCallback(() => { + setComponentInfo(null); + }, []); + + return ( + + {children} + + ); +} + +export function useComponentDebug() { + const context = useContext(ComponentDebugContext); + if (!context) { + throw new Error("useComponentDebug must be used within ComponentDebugProvider"); + } + return context; +} From 61d9c574a2360dc4383a26a54e17b0fff2030dfb Mon Sep 17 00:00:00 2001 From: mxrain Date: Wed, 7 Jan 2026 21:02:41 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E6=94=B9=E8=BF=9B=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E8=B0=83=E8=AF=95=E6=96=87=E4=BB=B6=E8=B7=AF=E5=BE=84=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=EF=BC=8C=E8=AF=B4=E6=98=8E=E4=BB=85=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=8F=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 27 ++++++++++++-- package.json | 1 + src/components/dev/ComponentDebugOverlay.tsx | 35 ++++++++++++++++--- src/components/settings/DeveloperSettings.tsx | 6 ++-- 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6b4924007..366e26382 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxycast", - "version": "0.29.0", + "version": "0.33.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxycast", - "version": "0.29.0", + "version": "0.33.0", "dependencies": { "@fabianlars/tauri-plugin-oauth": "^2", "@radix-ui/react-collapsible": "^1.1.12", @@ -45,6 +45,7 @@ "tailwind-merge": "^2.6.0" }, "devDependencies": { + "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@eslint/js": "^9.15.0", "@fast-check/vitest": "^0.2.4", "@tauri-apps/cli": "^2.0.0", @@ -178,6 +179,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -526,6 +528,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -569,6 +572,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3065,6 +3069,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -3517,6 +3522,7 @@ "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3538,6 +3544,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3549,6 +3556,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3621,6 +3629,7 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -3949,6 +3958,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4197,6 +4207,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4813,6 +4824,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5918,6 +5930,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -5947,6 +5960,7 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -7490,6 +7504,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7730,6 +7745,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -7742,6 +7758,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8639,6 +8656,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8780,6 +8798,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9049,6 +9068,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -9124,6 +9144,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -9680,6 +9701,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9693,6 +9715,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index ec01b500a..7189e22b4 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "tailwind-merge": "^2.6.0" }, "devDependencies": { + "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@eslint/js": "^9.15.0", "@fast-check/vitest": "^0.2.4", "@tauri-apps/cli": "^2.0.0", diff --git a/src/components/dev/ComponentDebugOverlay.tsx b/src/components/dev/ComponentDebugOverlay.tsx index c433d3ccb..56097bcbd 100644 --- a/src/components/dev/ComponentDebugOverlay.tsx +++ b/src/components/dev/ComponentDebugOverlay.tsx @@ -26,11 +26,38 @@ function getReactFiberInfo(element: HTMLElement): Partial | null if (type && typeof type === "function") { const name = type.displayName || type.name || "Anonymous"; if (!name.startsWith("_") && name !== "Anonymous") { - let filePath = "未知路径"; + // 尝试多种方式获取文件路径 + let filePath = ""; + + // 方式1: _debugSource (开发模式) if (fiber._debugSource) { - filePath = `${fiber._debugSource.fileName}:${fiber._debugSource.lineNumber}`; - } else if (type._source) { - filePath = `${type._source.fileName}:${type._source.lineNumber}`; + const source = fiber._debugSource; + filePath = source.fileName || ""; + if (source.lineNumber) { + filePath += `:${source.lineNumber}`; + } + } + // 方式2: type._source + else if (type._source) { + const source = type._source; + filePath = source.fileName || ""; + if (source.lineNumber) { + filePath += `:${source.lineNumber}`; + } + } + // 方式3: 尝试从函数的 toString 中提取(某些打包器会保留) + else if (type.toString) { + const funcStr = type.toString(); + // 检查是否有 sourceURL 注释 + const sourceMatch = funcStr.match(/\/\/# sourceURL=(.+)/); + if (sourceMatch) { + filePath = sourceMatch[1]; + } + } + + // 如果还是没有路径,显示提示信息 + if (!filePath) { + filePath = "仅开发模式可用 (需要 React DevTools)"; } const props = fiber.memoizedProps || {}; diff --git a/src/components/settings/DeveloperSettings.tsx b/src/components/settings/DeveloperSettings.tsx index 8da4d5a94..0ef8f27d6 100644 --- a/src/components/settings/DeveloperSettings.tsx +++ b/src/components/settings/DeveloperSettings.tsx @@ -38,9 +38,9 @@ export function DeveloperSettings() {

使用说明:

    -
  • 所有标记的组件会显示蓝色虚线轮廓
  • -
  • 鼠标悬浮时轮廓高亮显示
  • -
  • 按住 Alt 键点击组件可查看名称和文件路径
  • +
  • 按住 Alt 键 + 鼠标悬浮显示组件轮廓
  • +
  • Alt + 点击组件可查看名称和文件路径
  • +
  • 文件路径仅在开发模式 (npm run tauri dev) 下可用
)} From e696d800cca7936787d4c53c668e5c44f6e531bc Mon Sep 17 00:00:00 2001 From: mxrain Date: Wed, 7 Jan 2026 21:11:52 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E6=94=B9=E8=BF=9B=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E8=B0=83=E8=AF=95=20Fiber=20=E9=81=8D=E5=8E=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E4=BC=98=E5=85=88=E6=98=BE=E7=A4=BA=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/dev/ComponentDebugOverlay.tsx | 167 +++++++++++++------ vite.config.ts | 13 +- 2 files changed, 122 insertions(+), 58 deletions(-) diff --git a/src/components/dev/ComponentDebugOverlay.tsx b/src/components/dev/ComponentDebugOverlay.tsx index 56097bcbd..5cda153d5 100644 --- a/src/components/dev/ComponentDebugOverlay.tsx +++ b/src/components/dev/ComponentDebugOverlay.tsx @@ -20,72 +20,129 @@ function getReactFiberInfo(element: HTMLElement): Partial | null let fiber = (element as any)[fiberKey]; if (!fiber) return null; + // 收集组件链信息 + const componentChain: string[] = []; + let targetFiber = null; let depth = 0; + + // 遍历 Fiber 树找到最近的用户组件 while (fiber) { const type = fiber.type; if (type && typeof type === "function") { const name = type.displayName || type.name || "Anonymous"; - if (!name.startsWith("_") && name !== "Anonymous") { - // 尝试多种方式获取文件路径 - let filePath = ""; - - // 方式1: _debugSource (开发模式) - if (fiber._debugSource) { - const source = fiber._debugSource; - filePath = source.fileName || ""; - if (source.lineNumber) { - filePath += `:${source.lineNumber}`; - } + // 过滤掉内部组件和匿名组件 + if (!name.startsWith("_") && name !== "Anonymous" && !name.includes("Provider") && !name.includes("Consumer")) { + if (!targetFiber) { + targetFiber = fiber; } - // 方式2: type._source - else if (type._source) { - const source = type._source; - filePath = source.fileName || ""; - if (source.lineNumber) { - filePath += `:${source.lineNumber}`; - } - } - // 方式3: 尝试从函数的 toString 中提取(某些打包器会保留) - else if (type.toString) { - const funcStr = type.toString(); - // 检查是否有 sourceURL 注释 - const sourceMatch = funcStr.match(/\/\/# sourceURL=(.+)/); - if (sourceMatch) { - filePath = sourceMatch[1]; - } - } - - // 如果还是没有路径,显示提示信息 - if (!filePath) { - filePath = "仅开发模式可用 (需要 React DevTools)"; - } - - const props = fiber.memoizedProps || {}; - const safeProps: Record = {}; - for (const key of Object.keys(props)) { - const value = props[key]; - if (typeof value === "function") { - safeProps[key] = "[Function]"; - } else if (typeof value === "object" && value !== null) { - if (Array.isArray(value)) { - safeProps[key] = `[Array(${value.length})]`; - } else if (value.$$typeof) { - safeProps[key] = "[ReactElement]"; - } else { - safeProps[key] = "[Object]"; - } - } else { - safeProps[key] = value; - } - } - - return { name, filePath, props: safeProps, depth }; + componentChain.push(name); + if (componentChain.length >= 3) break; // 只收集前3个 } } fiber = fiber.return; depth++; } - return null; + + if (!targetFiber) { + // 如果没找到用户组件,使用第一个函数组件 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fiber = (element as any)[fiberKey]; + depth = 0; + while (fiber) { + const type = fiber.type; + if (type && typeof type === "function") { + const name = type.displayName || type.name; + if (name && name !== "Anonymous") { + targetFiber = fiber; + break; + } + } + fiber = fiber.return; + depth++; + } + } + + if (!targetFiber) return null; + + const type = targetFiber.type; + const name = type.displayName || type.name || "Unknown"; + + // 尝试多种方式获取文件路径 + let filePath = ""; + + // 方式1: _debugSource (开发模式 - React 17+) + if (targetFiber._debugSource) { + const source = targetFiber._debugSource; + filePath = source.fileName || ""; + if (source.lineNumber) { + filePath += `:${source.lineNumber}`; + if (source.columnNumber) { + filePath += `:${source.columnNumber}`; + } + } + } + // 方式2: __source prop (某些配置下) + else if (targetFiber.memoizedProps?.__source) { + const source = targetFiber.memoizedProps.__source; + filePath = source.fileName || ""; + if (source.lineNumber) { + filePath += `:${source.lineNumber}`; + } + } + // 方式3: _debugOwner 的 _debugSource + else if (targetFiber._debugOwner?._debugSource) { + const source = targetFiber._debugOwner._debugSource; + filePath = source.fileName || ""; + if (source.lineNumber) { + filePath += `:${source.lineNumber}`; + } + } + + // 简化路径显示 + if (filePath) { + // 移除绝对路径前缀,只保留 src/ 开始的部分 + const srcIndex = filePath.indexOf("/src/"); + if (srcIndex !== -1) { + filePath = filePath.substring(srcIndex + 1); + } + // Windows 路径 + const srcIndexWin = filePath.indexOf("\\src\\"); + if (srcIndexWin !== -1) { + filePath = filePath.substring(srcIndexWin + 1).replace(/\\/g, "/"); + } + } + + if (!filePath) { + filePath = "生产构建中不可用"; + } + + // 获取 props + const props = targetFiber.memoizedProps || {}; + const safeProps: Record = {}; + for (const key of Object.keys(props)) { + if (key.startsWith("_") || key === "__source" || key === "__self") continue; + const value = props[key]; + if (typeof value === "function") { + safeProps[key] = "[Function]"; + } else if (typeof value === "object" && value !== null) { + if (Array.isArray(value)) { + safeProps[key] = `[Array(${value.length})]`; + } else if (value.$$typeof) { + safeProps[key] = "[ReactElement]"; + } else { + safeProps[key] = "[Object]"; + } + } else { + safeProps[key] = value; + } + } + + return { + name, + filePath, + props: safeProps, + depth, + }; } /** 组件信息弹窗 */ diff --git a/vite.config.ts b/vite.config.ts index b9b54df1e..b8472d8f1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,8 +4,15 @@ import react from "@vitejs/plugin-react"; import svgr from "vite-plugin-svgr"; import path from "path"; -export default defineConfig({ - plugins: [react(), svgr()], +export default defineConfig(({ mode }) => ({ + plugins: [ + react({ + // 开发模式下启用 jsxDev 以获取组件源码位置 + jsxRuntime: mode === "development" ? "automatic" : "automatic", + jsxImportSource: "react", + }), + svgr(), + ], resolve: { alias: { "@": path.resolve(__dirname, "./src"), @@ -29,4 +36,4 @@ export default defineConfig({ "**/src-tauri/**", ], }, -}); +})); From 8ffe9d446dfcbc5309155566e81e397d9343bbec Mon Sep 17 00:00:00 2001 From: mxrain Date: Wed, 7 Jan 2026 23:45:41 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E8=B0=83=E8=AF=95=E8=A6=86=E7=9B=96=E5=B1=82=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/dev/ComponentDebugOverlay.tsx | 655 +++++++++++++++---- src/contexts/ComponentDebugContext.tsx | 5 + 2 files changed, 527 insertions(+), 133 deletions(-) diff --git a/src/components/dev/ComponentDebugOverlay.tsx b/src/components/dev/ComponentDebugOverlay.tsx index 5cda153d5..42b38902d 100644 --- a/src/components/dev/ComponentDebugOverlay.tsx +++ b/src/components/dev/ComponentDebugOverlay.tsx @@ -2,77 +2,253 @@ * @file ComponentDebugOverlay.tsx * @description 组件视图调试覆盖层 - Alt+悬浮显示轮廓,Alt+点击显示组件信息 */ -import { useEffect, useCallback, useState } from "react"; +import React, { useEffect, useState, useMemo } from "react"; import { useComponentDebug, ComponentInfo } from "@/contexts/ComponentDebugContext"; -import { X, Copy, Check, Component, FileCode, Layers, Hash } from "lucide-react"; +import { X, Copy, Check, Component, FileCode, Layers, Hash, ChevronUp } from "lucide-react"; + +// ============================================================================ +// 错误边界组件 +// ============================================================================ + +interface ErrorBoundaryState { + hasError: boolean; + errorCount: number; +} + +class DebugErrorBoundary extends React.Component< + { children: React.ReactNode }, + ErrorBoundaryState +> { + constructor(props: { children: React.ReactNode }) { + super(props); + this.state = { hasError: false, errorCount: 0 }; + } + + static getDerivedStateFromError(error: Error): Partial { + console.warn("[ComponentDebugOverlay] 捕获到渲染错误:", error.message); + return { hasError: true }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.warn("[ComponentDebugOverlay] 错误详情:", error, errorInfo); + } + + componentDidUpdate(prevProps: { children: React.ReactNode }) { + // 当 children 变化时,尝试恢复 + if (this.state.hasError && prevProps.children !== this.props.children) { + this.setState({ hasError: false }); + } + } + + render() { + if (this.state.hasError) { + // 使用 setTimeout 在下一帧尝试恢复 + setTimeout(() => { + if (this.state.hasError) { + this.setState((prev) => ({ + hasError: false, + errorCount: prev.errorCount + 1 + })); + } + }, 100); + + // 如果错误次数过多,完全禁用 + if (this.state.errorCount > 5) { + return null; + } + return null; + } + return this.props.children; + } +} + +// ============================================================================ +// 类型定义 +// ============================================================================ + +/** React Fiber 调试源信息 */ +interface DebugSource { + fileName?: string; + lineNumber?: number; + columnNumber?: number; +} + +/** Fiber 节点类型(支持 memo/forwardRef 包装) */ +interface FiberType { + displayName?: string; + name?: string; + $typeof?: symbol; + type?: FiberType; // for memo wrapped components + render?: FiberType; // for forwardRef wrapped components +} + +/** React Fiber 节点结构 */ +interface FiberNode { + type: FiberType | ((...args: unknown[]) => unknown) | null; + return: FiberNode | null; + memoizedProps: Record & { __source?: DebugSource } | null; + _debugSource?: DebugSource; + _debugOwner?: FiberNode; + stateNode?: HTMLElement | null; // DOM 元素引用 +} + +// ============================================================================ +// Fiber 工具函数 +// ============================================================================ /** - * 从 React Fiber 节点获取组件信息 + * 判断组件名称是否为 styled-components 生成的名称 + * styled-components 的名称格式通常是 "styled.xxx" 或 "Styled(xxx)" */ -function getReactFiberInfo(element: HTMLElement): Partial | null { - const fiberKey = Object.keys(element).find( - (key) => key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$") +function isStyledComponentName(name: string): boolean { + if (!name) return false; + return ( + name.startsWith("styled.") || + name.startsWith("Styled(") || + name === "styled" || + /^styled[A-Z]/.test(name) // styledButton, styledDiv 等 ); +} - if (!fiberKey) return null; +/** + * 从 Fiber 类型中提取组件名称 + * 支持 memo、forwardRef、styled-components 等包装组件 + */ +function getComponentName(type: FiberType | ((...args: unknown[]) => unknown) | null): string { + if (!type) return "Unknown"; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let fiber = (element as any)[fiberKey]; - if (!fiber) return null; - - // 收集组件链信息 - const componentChain: string[] = []; - let targetFiber = null; - let depth = 0; - - // 遍历 Fiber 树找到最近的用户组件 - while (fiber) { - const type = fiber.type; - if (type && typeof type === "function") { - const name = type.displayName || type.name || "Anonymous"; - // 过滤掉内部组件和匿名组件 - if (!name.startsWith("_") && name !== "Anonymous" && !name.includes("Provider") && !name.includes("Consumer")) { - if (!targetFiber) { - targetFiber = fiber; - } - componentChain.push(name); - if (componentChain.length >= 3) break; // 只收集前3个 + // 直接函数组件 + if (typeof type === "function") { + const funcType = type as { displayName?: string; name?: string; styledComponentId?: string; target?: unknown }; + + // styled-components 有 styledComponentId 属性,尝试获取更好的名称 + if (funcType.styledComponentId) { + // 如果有 displayName 且不是 styled.xxx 格式,使用它 + if (funcType.displayName && !isStyledComponentName(funcType.displayName)) { + return funcType.displayName; } + // 否则返回特殊标记,让调用者知道这是 styled-component + return `[styled]${funcType.displayName || funcType.name || "Component"}`; } - fiber = fiber.return; - depth++; + + return funcType.displayName || funcType.name || "Anonymous"; } - if (!targetFiber) { - // 如果没找到用户组件,使用第一个函数组件 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fiber = (element as any)[fiberKey]; - depth = 0; - while (fiber) { - const type = fiber.type; - if (type && typeof type === "function") { - const name = type.displayName || type.name; - if (name && name !== "Anonymous") { - targetFiber = fiber; - break; - } + // 对象类型(memo、forwardRef 等) + if (typeof type === "object") { + const fiberType = type as FiberType & { styledComponentId?: string; target?: unknown }; + + // styled-components 检测 + if (fiberType.styledComponentId) { + if (fiberType.displayName && !isStyledComponentName(fiberType.displayName)) { + return fiberType.displayName; } - fiber = fiber.return; - depth++; + return `[styled]${fiberType.displayName || fiberType.name || "Component"}`; + } + + // 直接有 displayName 或 name + if (fiberType.displayName) return fiberType.displayName; + if (fiberType.name) return fiberType.name; + + // memo 包装:type.type 是内部组件 + if (fiberType.type) { + return getComponentName(fiberType.type); + } + + // forwardRef 包装:type.render 是内部组件 + if (fiberType.render) { + return getComponentName(fiberType.render); } } - if (!targetFiber) return null; + return "Unknown"; +} - const type = targetFiber.type; - const name = type.displayName || type.name || "Unknown"; +/** + * 判断 Fiber 节点是否为有效的用户组件 + * 过滤掉内部组件、匿名组件、React 内置组件和 styled-components + */ +function isValidUserComponent(fiber: FiberNode): boolean { + const type = fiber.type; + if (!type) return false; + + // 必须是函数组件或对象类型(memo/forwardRef) + if (typeof type !== "function" && typeof type !== "object") return false; + + const name = getComponentName(type); + + // 过滤掉匿名组件 + if (name === "Anonymous" || name === "Unknown") return false; + + // 过滤掉 styled-components(以 [styled] 开头的是我们标记的) + if (name.startsWith("[styled]") || isStyledComponentName(name)) return false; + // 过滤掉以下划线开头的内部组件 + if (name.startsWith("_")) return false; + + // 过滤掉 React 内置组件(如 Fragment、Suspense 等) + const reactInternals = ["Fragment", "Suspense", "StrictMode", "Profiler"]; + if (reactInternals.includes(name)) return false; + + return true; +} + +// ============================================================================ +// 工具函数 +// ============================================================================ + +/** + * 节流函数 - 限制函数在指定时间间隔内最多执行一次 + * @param fn 要节流的函数 + * @param delay 节流间隔(毫秒) + * @returns 节流后的函数 + */ +export function throttle) => void>( + fn: T, + delay: number +): (...args: Parameters) => void { + let lastCall = 0; + return (...args: Parameters) => { + const now = Date.now(); + if (now - lastCall >= delay) { + lastCall = now; + fn(...args); + } + }; +} + +// ============================================================================ +// 配置常量 +// ============================================================================ + +const DEBUG_CONFIG = { + // 弹窗位置偏移 + POPUP_WIDTH_OFFSET: 470, + POPUP_HEIGHT_OFFSET: 350, + // Props 显示限制 + MAX_PROPS_DISPLAY: 8, + // 高亮颜色 + HOVER_HIGHLIGHT_COLOR: "rgba(59, 130, 246, 0.8)", // 蓝色 + HOVER_HIGHLIGHT_BG: "rgba(59, 130, 246, 0.1)", + SELECTED_HIGHLIGHT_COLOR: "rgba(34, 197, 94, 0.9)", // 绿色 + SELECTED_HIGHLIGHT_BG: "rgba(34, 197, 94, 0.15)", + // 节流间隔 + MOUSEMOVE_THROTTLE_MS: 16, +} as const; + +/** + * 从 Fiber 节点提取组件信息 + */ +function extractFiberInfo(fiber: FiberNode, element: HTMLElement, x: number, y: number): ComponentInfo | null { + try { + if (!fiber || !element) return null; + + const name = getComponentName(fiber.type); // 尝试多种方式获取文件路径 let filePath = ""; - // 方式1: _debugSource (开发模式 - React 17+) - if (targetFiber._debugSource) { - const source = targetFiber._debugSource; + if (fiber._debugSource) { + const source = fiber._debugSource; filePath = source.fileName || ""; if (source.lineNumber) { filePath += `:${source.lineNumber}`; @@ -80,18 +256,14 @@ function getReactFiberInfo(element: HTMLElement): Partial | null filePath += `:${source.columnNumber}`; } } - } - // 方式2: __source prop (某些配置下) - else if (targetFiber.memoizedProps?.__source) { - const source = targetFiber.memoizedProps.__source; + } else if (fiber.memoizedProps?.__source) { + const source = fiber.memoizedProps.__source; filePath = source.fileName || ""; if (source.lineNumber) { filePath += `:${source.lineNumber}`; } - } - // 方式3: _debugOwner 的 _debugSource - else if (targetFiber._debugOwner?._debugSource) { - const source = targetFiber._debugOwner._debugSource; + } else if (fiber._debugOwner?._debugSource) { + const source = fiber._debugOwner._debugSource; filePath = source.fileName || ""; if (source.lineNumber) { filePath += `:${source.lineNumber}`; @@ -100,12 +272,10 @@ function getReactFiberInfo(element: HTMLElement): Partial | null // 简化路径显示 if (filePath) { - // 移除绝对路径前缀,只保留 src/ 开始的部分 const srcIndex = filePath.indexOf("/src/"); if (srcIndex !== -1) { filePath = filePath.substring(srcIndex + 1); } - // Windows 路径 const srcIndexWin = filePath.indexOf("\\src\\"); if (srcIndexWin !== -1) { filePath = filePath.substring(srcIndexWin + 1).replace(/\\/g, "/"); @@ -117,7 +287,7 @@ function getReactFiberInfo(element: HTMLElement): Partial | null } // 获取 props - const props = targetFiber.memoizedProps || {}; + const props = fiber.memoizedProps || {}; const safeProps: Record = {}; for (const key of Object.keys(props)) { if (key.startsWith("_") || key === "__source" || key === "__self") continue; @@ -127,7 +297,7 @@ function getReactFiberInfo(element: HTMLElement): Partial | null } else if (typeof value === "object" && value !== null) { if (Array.isArray(value)) { safeProps[key] = `[Array(${value.length})]`; - } else if (value.$$typeof) { + } else if ((value as Record).$$typeof) { safeProps[key] = "[ReactElement]"; } else { safeProps[key] = "[Object]"; @@ -137,47 +307,223 @@ function getReactFiberInfo(element: HTMLElement): Partial | null } } + // 计算深度 + let depth = 0; + let tempFiber = fiber; + while (tempFiber.return) { + tempFiber = tempFiber.return; + depth++; + } + return { name, filePath, props: safeProps, depth, + tagName: element.tagName, + x, + y, + element, + fiber, }; + } catch { + // 发生错误时返回 null,避免白屏 + return null; + } +} + +/** + * 从 React Fiber 节点获取组件信息 + */ +function getReactFiberInfo(element: HTMLElement, x: number, y: number): ComponentInfo | null { + try { + const fiberKey = Object.keys(element).find( + (key) => key.startsWith("__reactFiber$") || key.startsWith("__reactInternalInstance$") + ); + + if (!fiberKey) return null; + + let fiber: FiberNode | null = (element as unknown as Record)[fiberKey]; + if (!fiber) return null; + + // 遍历 Fiber 树找到最近的用户组件 + while (fiber) { + if (isValidUserComponent(fiber)) { + return extractFiberInfo(fiber, element, x, y); + } + fiber = fiber.return; + } + + return null; + } catch { + // 发生错误时返回 null,避免白屏 + return null; + } +} + +/** + * 从 Fiber 节点向下查找最近的 DOM 元素 + */ +function findDomElement(fiber: FiberNode | null): HTMLElement | null { + if (!fiber) return null; + + // 如果当前节点有 stateNode 且是 DOM 元素 + if (fiber.stateNode instanceof HTMLElement) { + return fiber.stateNode; + } + + return null; +} + +/** + * 获取父组件信息 + */ +function getParentComponentInfo(currentFiber: FiberNode | unknown, x: number, y: number, fallbackElement: HTMLElement): ComponentInfo | null { + try { + if (!currentFiber) return null; + + let fiber = (currentFiber as FiberNode).return; + + while (fiber) { + if (isValidUserComponent(fiber)) { + // 尝试找到父组件对应的 DOM 元素 + const parentElement = findDomElement(fiber) || fallbackElement; + return extractFiberInfo(fiber, parentElement, x, y); + } + fiber = fiber.return; + } + + return null; + } catch { + // 发生错误时返回 null,避免白屏 + return null; + } +} + +/** 选中组件的持久高亮边框 */ +function SelectedHighlight({ element }: { element: HTMLElement | undefined }) { + const [rect, setRect] = useState(null); + + useEffect(() => { + if (!element) { + setRect(null); + return; + } + + const updateRect = () => { + try { + if (document.contains(element)) { + setRect(element.getBoundingClientRect()); + } else { + setRect(null); + } + } catch { + setRect(null); + } + }; + + updateRect(); + + // 监听滚动和窗口调整 + window.addEventListener("scroll", updateRect, true); + window.addEventListener("resize", updateRect); + + // 监听 DOM 变化(元素可能被移除) + const observer = new MutationObserver(() => { + updateRect(); + }); + + try { + observer.observe(document.body, { childList: true, subtree: true }); + } catch { + // 忽略 observer 错误 + } + + return () => { + window.removeEventListener("scroll", updateRect, true); + window.removeEventListener("resize", updateRect); + observer.disconnect(); + }; + }, [element]); + + if (!rect) return null; + + return ( +
+ ); } /** 组件信息弹窗 */ function ComponentInfoPopup() { - const { componentInfo, hideComponentInfo } = useComponentDebug(); + const { componentInfo, hideComponentInfo, showComponentInfo } = useComponentDebug(); const [copiedField, setCopiedField] = useState(null); + // 使用 useMemo 缓存 propsEntries 计算结果 - 必须在所有条件返回之前调用 + const propsEntries = useMemo(() => { + if (!componentInfo?.props) return []; + return Object.entries(componentInfo.props).filter( + ([key]) => key !== "children" + ); + }, [componentInfo?.props]); + + // 早期返回必须在所有 hooks 之后 if (!componentInfo) return null; + // 渲染选中高亮边框 + const selectedHighlight = ; + const handleCopy = async (text: string, field: string) => { await navigator.clipboard.writeText(text); setCopiedField(field); setTimeout(() => setCopiedField(null), 2000); }; - const propsEntries = Object.entries(componentInfo.props || {}).filter( - ([key]) => key !== "children" - ); + const handleSelectParent = () => { + if (!componentInfo.fiber) return; + + const parentInfo = getParentComponentInfo( + componentInfo.fiber, + componentInfo.x, + componentInfo.y, + componentInfo.element || document.body + ); + + if (parentInfo) { + showComponentInfo(parentInfo); + } + }; + + // 检查是否有父组件 + const hasParent = componentInfo.fiber?.return != null; return ( + <>
e.stopPropagation()} > {/* 标题栏 */}
- + 组件信息
-
@@ -188,11 +534,11 @@ function ComponentInfoPopup() {
-
组件名称
+
组件名称
- {componentInfo.name} -
@@ -202,11 +548,11 @@ function ComponentInfoPopup() {
-
文件路径
+
文件路径
- {componentInfo.filePath} -
@@ -216,8 +562,8 @@ function ComponentInfoPopup() {
-
DOM 元素
- <{componentInfo.tagName.toLowerCase()}> +
DOM 元素
+ <{componentInfo.tagName.toLowerCase()}>
@@ -225,27 +571,27 @@ function ComponentInfoPopup() {
-
组件层级
- 第 {componentInfo.depth} 层 +
组件层级
+ 第 {componentInfo.depth} 层
{/* Props */} {propsEntries.length > 0 && ( -
-
Props
-
+
+
Props
+
- {propsEntries.slice(0, 10).map(([key, value]) => ( + {propsEntries.slice(0, DEBUG_CONFIG.MAX_PROPS_DISPLAY).map(([key, value]) => (
{key}: - + {typeof value === "string" ? `"${value}"` : String(value)}
))} - {propsEntries.length > 10 && ( -
... 还有 {propsEntries.length - 10} 个属性
+ {propsEntries.length > DEBUG_CONFIG.MAX_PROPS_DISPLAY && ( +
... 还有 {propsEntries.length - DEBUG_CONFIG.MAX_PROPS_DISPLAY} 个属性
)}
@@ -253,11 +599,23 @@ function ComponentInfoPopup() { )}
- {/* 底部提示 */} -
-

提示: 按 Esc 或点击其他区域关闭

+ {/* 底部操作栏 */} +
+

按 Esc 关闭

+ {hasParent && ( + + )}
+ {/* 选中高亮边框 - 渲染在弹窗外部 */} + {selectedHighlight} + ); } @@ -267,7 +625,6 @@ function DebugInteractionHandler() { const [altPressed, setAltPressed] = useState(false); const [hoveredElement, setHoveredElement] = useState(null); - // 监听 Alt 键 useEffect(() => { if (!enabled) return; @@ -287,7 +644,6 @@ function DebugInteractionHandler() { } }; - // 窗口失焦时重置状态 const handleBlur = () => { setAltPressed(false); setHoveredElement(null); @@ -304,52 +660,61 @@ function DebugInteractionHandler() { }; }, [enabled, hideComponentInfo]); - // 监听鼠标移动(仅在 Alt 按下时) useEffect(() => { if (!enabled || !altPressed) { setHoveredElement(null); return; } - const handleMouseMove = (e: MouseEvent) => { - const target = e.target as HTMLElement; - // 忽略弹窗内的元素 - if (target.closest(".component-debug-popup")) return; - setHoveredElement(target); - }; + const handleMouseMove = throttle((e: MouseEvent) => { + try { + const target = e.target as HTMLElement; + if (target.closest(".component-debug-popup")) return; + setHoveredElement(target); + } catch { + // 静默处理错误 + } + }, DEBUG_CONFIG.MOUSEMOVE_THROTTLE_MS); document.addEventListener("mousemove", handleMouseMove); return () => document.removeEventListener("mousemove", handleMouseMove); }, [enabled, altPressed]); - // 监听点击(仅在 Alt 按下时) useEffect(() => { if (!enabled) return; const handleClick = (e: MouseEvent) => { - const target = e.target as HTMLElement; + try { + const target = e.target as HTMLElement; - // 点击弹窗外部关闭弹窗 - if (!target.closest(".component-debug-popup")) { - if (!e.altKey) { - hideComponentInfo(); - return; + if (!target.closest(".component-debug-popup")) { + if (!e.altKey) { + hideComponentInfo(); + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const info = getReactFiberInfo(target, e.clientX + 10, e.clientY + 10); + if (info) { + showComponentInfo(info); + } else { + showComponentInfo({ + name: "DOM Element", + filePath: "非 React 组件", + props: {}, + depth: 0, + tagName: target.tagName || "UNKNOWN", + x: e.clientX + 10, + y: e.clientY + 10, + element: target, + }); + } } - - // Alt + 点击显示组件信息 - e.preventDefault(); - e.stopPropagation(); - - const fiberInfo = getReactFiberInfo(target); - showComponentInfo({ - name: fiberInfo?.name || "DOM Element", - filePath: fiberInfo?.filePath || "非 React 组件", - props: fiberInfo?.props || {}, - depth: fiberInfo?.depth || 0, - tagName: target.tagName, - x: e.clientX + 10, - y: e.clientY + 10, - }); + } catch { + // 发生错误时静默处理,避免白屏 + console.warn("[ComponentDebugOverlay] 点击处理出错"); } }; @@ -357,10 +722,19 @@ function DebugInteractionHandler() { return () => document.removeEventListener("click", handleClick, true); }, [enabled, showComponentInfo, hideComponentInfo]); - // 渲染悬浮高亮框 if (!altPressed || !hoveredElement) return null; - const rect = hoveredElement.getBoundingClientRect(); + // 安全获取元素边界,如果元素已被移除则返回 null + let rect: DOMRect | null = null; + try { + if (document.contains(hoveredElement)) { + rect = hoveredElement.getBoundingClientRect(); + } + } catch { + // 静默处理错误 + } + + if (!rect) return null; return (
); } -export function ComponentDebugOverlay() { - const { enabled } = useComponentDebug(); - - if (!enabled) return null; - +/** 安全的调试覆盖层内容 */ +function SafeDebugContent() { return ( <> @@ -392,3 +763,21 @@ export function ComponentDebugOverlay() { ); } + +export function ComponentDebugOverlay() { + const { enabled } = useComponentDebug(); + + // 仅在开发环境启用 + if (process.env.NODE_ENV !== "development") return null; + if (!enabled) return null; + + // 使用错误边界包装,防止任何错误导致白屏 + return ( + + + + ); +} + +// 导出用于测试 +export { SelectedHighlight, getComponentName, isValidUserComponent, DEBUG_CONFIG }; diff --git a/src/contexts/ComponentDebugContext.tsx b/src/contexts/ComponentDebugContext.tsx index ddc7e9ec0..d50da7887 100644 --- a/src/contexts/ComponentDebugContext.tsx +++ b/src/contexts/ComponentDebugContext.tsx @@ -12,6 +12,11 @@ export interface ComponentInfo { tagName: string; x: number; y: number; + /** 当前 DOM 元素引用,用于查找父组件 */ + element?: HTMLElement; + /** 当前 Fiber 节点,用于向上遍历 */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fiber?: any; } interface ComponentDebugContextType {