diff --git a/package-lock.json b/package-lock.json index f4c6706ad..cfe1eb350 100644 --- a/package-lock.json +++ b/package-lock.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", @@ -191,6 +192,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -539,6 +541,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -582,6 +585,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -3078,6 +3082,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", @@ -3545,6 +3550,7 @@ "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3566,6 +3572,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" @@ -3577,6 +3584,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3649,6 +3657,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", @@ -4016,6 +4025,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4264,6 +4274,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4880,6 +4891,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", @@ -6038,6 +6050,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6096,6 +6109,7 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -7645,6 +7659,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7885,6 +7900,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" }, @@ -7897,6 +7913,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" @@ -8841,6 +8858,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8973,6 +8991,17 @@ "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, "bin": { "tsx": "dist/cli.mjs" }, @@ -9713,6 +9742,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -9788,6 +9818,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -10344,6 +10375,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10357,6 +10389,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 cb5216b23..efc8fedeb 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,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/App.tsx b/src/App.tsx index 94e554764..d77c9c332 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,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"; /** * 页面类型定义 @@ -242,24 +244,28 @@ function AppContent() { // 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..42b38902d --- /dev/null +++ b/src/components/dev/ComponentDebugOverlay.tsx @@ -0,0 +1,783 @@ +/** + * @file ComponentDebugOverlay.tsx + * @description 组件视图调试覆盖层 - Alt+悬浮显示轮廓,Alt+点击显示组件信息 + */ +import React, { useEffect, useState, useMemo } from "react"; +import { useComponentDebug, ComponentInfo } from "@/contexts/ComponentDebugContext"; +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 工具函数 +// ============================================================================ + +/** + * 判断组件名称是否为 styled-components 生成的名称 + * styled-components 的名称格式通常是 "styled.xxx" 或 "Styled(xxx)" + */ +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 等 + ); +} + +/** + * 从 Fiber 类型中提取组件名称 + * 支持 memo、forwardRef、styled-components 等包装组件 + */ +function getComponentName(type: FiberType | ((...args: unknown[]) => unknown) | null): string { + if (!type) return "Unknown"; + + // 直接函数组件 + 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"}`; + } + + return funcType.displayName || funcType.name || "Anonymous"; + } + + // 对象类型(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; + } + 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); + } + } + + return "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 = ""; + + if (fiber._debugSource) { + const source = fiber._debugSource; + filePath = source.fileName || ""; + if (source.lineNumber) { + filePath += `:${source.lineNumber}`; + if (source.columnNumber) { + filePath += `:${source.columnNumber}`; + } + } + } else if (fiber.memoizedProps?.__source) { + const source = fiber.memoizedProps.__source; + filePath = source.fileName || ""; + if (source.lineNumber) { + filePath += `:${source.lineNumber}`; + } + } else if (fiber._debugOwner?._debugSource) { + const source = fiber._debugOwner._debugSource; + filePath = source.fileName || ""; + if (source.lineNumber) { + filePath += `:${source.lineNumber}`; + } + } + + // 简化路径显示 + if (filePath) { + const srcIndex = filePath.indexOf("/src/"); + if (srcIndex !== -1) { + filePath = filePath.substring(srcIndex + 1); + } + const srcIndexWin = filePath.indexOf("\\src\\"); + if (srcIndexWin !== -1) { + filePath = filePath.substring(srcIndexWin + 1).replace(/\\/g, "/"); + } + } + + if (!filePath) { + filePath = "生产构建中不可用"; + } + + // 获取 props + const props = fiber.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 as Record).$$typeof) { + safeProps[key] = "[ReactElement]"; + } else { + safeProps[key] = "[Object]"; + } + } else { + safeProps[key] = value; + } + } + + // 计算深度 + 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, 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 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()} + > + {/* 标题栏 */} +
+
+ + 组件信息 +
+ +
+ + {/* 内容区域 */} +
+ {/* 组件名称 */} +
+ +
+
组件名称
+
+ {componentInfo.name} + +
+
+
+ + {/* 文件路径 */} +
+ +
+
文件路径
+
+ {componentInfo.filePath} + +
+
+
+ + {/* HTML 标签 */} +
+ +
+
DOM 元素
+ <{componentInfo.tagName.toLowerCase()}> +
+
+ + {/* 组件层级 */} +
+ +
+
组件层级
+ 第 {componentInfo.depth} 层 +
+
+ + {/* Props */} + {propsEntries.length > 0 && ( +
+
Props
+
+
+ {propsEntries.slice(0, DEBUG_CONFIG.MAX_PROPS_DISPLAY).map(([key, value]) => ( +
+ {key}: + + {typeof value === "string" ? `"${value}"` : String(value)} + +
+ ))} + {propsEntries.length > DEBUG_CONFIG.MAX_PROPS_DISPLAY && ( +
... 还有 {propsEntries.length - DEBUG_CONFIG.MAX_PROPS_DISPLAY} 个属性
+ )} +
+
+
+ )} +
+ + {/* 底部操作栏 */} +
+

按 Esc 关闭

+ {hasParent && ( + + )} +
+
+ {/* 选中高亮边框 - 渲染在弹窗外部 */} + {selectedHighlight} + + ); +} + +/** 调试交互处理 */ +function DebugInteractionHandler() { + const { enabled, showComponentInfo, hideComponentInfo } = useComponentDebug(); + const [altPressed, setAltPressed] = useState(false); + const [hoveredElement, setHoveredElement] = useState(null); + + 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]); + + useEffect(() => { + if (!enabled || !altPressed) { + setHoveredElement(null); + return; + } + + 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]); + + useEffect(() => { + if (!enabled) return; + + const handleClick = (e: MouseEvent) => { + try { + const target = e.target as HTMLElement; + + 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, + }); + } + } + } catch { + // 发生错误时静默处理,避免白屏 + console.warn("[ComponentDebugOverlay] 点击处理出错"); + } + }; + + document.addEventListener("click", handleClick, true); + return () => document.removeEventListener("click", handleClick, true); + }, [enabled, showComponentInfo, hideComponentInfo]); + + if (!altPressed || !hoveredElement) return null; + + // 安全获取元素边界,如果元素已被移除则返回 null + let rect: DOMRect | null = null; + try { + if (document.contains(hoveredElement)) { + rect = hoveredElement.getBoundingClientRect(); + } + } catch { + // 静默处理错误 + } + + if (!rect) return null; + + return ( +
+ ); +} + +/** 安全的调试覆盖层内容 */ +function SafeDebugContent() { + return ( + <> + +
+ +
+ + ); +} + +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/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..0ef8f27d6 --- /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 键 + 鼠标悬浮显示组件轮廓
  • +
  • Alt + 点击组件可查看名称和文件路径
  • +
  • 文件路径仅在开发模式 (npm run tauri dev) 下可用
  • +
+
+ )} +
+
+ ); +} 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..d50da7887 --- /dev/null +++ b/src/contexts/ComponentDebugContext.tsx @@ -0,0 +1,80 @@ +/** + * @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; + /** 当前 DOM 元素引用,用于查找父组件 */ + element?: HTMLElement; + /** 当前 Fiber 节点,用于向上遍历 */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fiber?: any; +} + +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; +} 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/**", ], }, -}); +}));