From 8a4f2de88775bb3833628f64e820b666c74d9b65 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Tue, 30 Sep 2025 18:13:02 +0800 Subject: [PATCH] Revert "Refactor components to remove forwardRef and simplify props handling" This reverts commit 1cd013fb94b4e0be37bc966e5401c2c9b9bb0bb4. --- .../connection/connection-detail.tsx | 72 +- .../home/enhanced-canvas-traffic-graph.tsx | 1634 +++++++++-------- src/components/layout/traffic-graph.tsx | 6 +- src/components/profile/profile-viewer.tsx | 608 +++--- src/components/proxy/proxy-groups.tsx | 10 +- src/components/setting/mods/backup-viewer.tsx | 14 +- .../setting/mods/clash-core-viewer.tsx | 8 +- .../setting/mods/clash-port-viewer.tsx | 14 +- src/components/setting/mods/config-viewer.tsx | 7 +- .../setting/mods/controller-viewer.tsx | 8 +- src/components/setting/mods/dns-viewer.tsx | 8 +- .../setting/mods/external-controller-cors.tsx | 359 ++-- src/components/setting/mods/hotkey-viewer.tsx | 8 +- src/components/setting/mods/layout-viewer.tsx | 8 +- .../setting/mods/lite-mode-viewer.tsx | 8 +- src/components/setting/mods/misc-viewer.tsx | 8 +- .../setting/mods/network-interface-viewer.tsx | 8 +- .../setting/mods/sysproxy-viewer.tsx | 16 +- src/components/setting/mods/theme-viewer.tsx | 8 +- src/components/setting/mods/tun-viewer.tsx | 8 +- src/components/setting/mods/update-viewer.tsx | 14 +- src/components/setting/mods/web-ui-viewer.tsx | 8 +- src/components/test/test-viewer.tsx | 9 +- src/providers/app-data-provider.tsx | 14 +- src/providers/chain-proxy-provider.tsx | 8 +- 25 files changed, 1474 insertions(+), 1399 deletions(-) diff --git a/src/components/connection/connection-detail.tsx b/src/components/connection/connection-detail.tsx index fe563c0ea..9eb0f5636 100644 --- a/src/components/connection/connection-detail.tsx +++ b/src/components/connection/connection-detail.tsx @@ -2,7 +2,7 @@ import { Box, Button, Snackbar, useTheme } from "@mui/material"; import { useLockFn } from "ahooks"; import dayjs from "dayjs"; import { t } from "i18next"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { deleteConnection } from "@/services/cmds"; import parseTraffic from "@/utils/parse-traffic"; @@ -11,43 +11,45 @@ export interface ConnectionDetailRef { open: (detail: IConnectionsItem) => void; } -export const ConnectionDetail = ({ ref, ...props }) => { - const [open, setOpen] = useState(false); - const [detail, setDetail] = useState(null!); - const theme = useTheme(); +export const ConnectionDetail = forwardRef( + (props, ref) => { + const [open, setOpen] = useState(false); + const [detail, setDetail] = useState(null!); + const theme = useTheme(); - useImperativeHandle(ref, () => ({ - open: (detail: IConnectionsItem) => { - if (open) return; - setOpen(true); - setDetail(detail); - }, - })); + useImperativeHandle(ref, () => ({ + open: (detail: IConnectionsItem) => { + if (open) return; + setOpen(true); + setDetail(detail); + }, + })); - const onClose = () => setOpen(false); + const onClose = () => setOpen(false); - return ( - - ) : null - } - /> - ); -}; + return ( + + ) : null + } + /> + ); + }, +); interface InnerProps { data: IConnectionsItem; diff --git a/src/components/home/enhanced-canvas-traffic-graph.tsx b/src/components/home/enhanced-canvas-traffic-graph.tsx index 9ccd5cc9f..2f8a47196 100644 --- a/src/components/home/enhanced-canvas-traffic-graph.tsx +++ b/src/components/home/enhanced-canvas-traffic-graph.tsx @@ -1,5 +1,6 @@ import { Box, useTheme } from "@mui/material"; import { + forwardRef, useImperativeHandle, useState, useEffect, @@ -80,525 +81,578 @@ const GRAPH_CONFIG = { * 稳定版Canvas流量图表组件 * 修复闪烁问题,添加时间轴显示 */ -export const EnhancedCanvasTrafficGraph = memo(({ ref, ...props }) => { - const theme = useTheme(); - const { t } = useTranslation(); +export const EnhancedCanvasTrafficGraph = memo( + forwardRef((props, ref) => { + const theme = useTheme(); + const { t } = useTranslation(); - // 使用增强版全局流量数据管理 - const { dataPoints, getDataForTimeRange, isDataFresh, samplerStats } = - useTrafficGraphDataEnhanced(); + // 使用增强版全局流量数据管理 + const { dataPoints, getDataForTimeRange, isDataFresh, samplerStats } = + useTrafficGraphDataEnhanced(); - // 基础状态 - const [timeRange, setTimeRange] = useState(10); - const [chartStyle, setChartStyle] = useState<"bezier" | "line">("bezier"); + // 基础状态 + const [timeRange, setTimeRange] = useState(10); + const [chartStyle, setChartStyle] = useState<"bezier" | "line">("bezier"); - // 悬浮提示状态 - const [tooltipData, setTooltipData] = useState({ - x: 0, - y: 0, - upSpeed: "", - downSpeed: "", - timestamp: "", - visible: false, - dataIndex: -1, - highlightY: 0, - }); + // 悬浮提示状态 + const [tooltipData, setTooltipData] = useState({ + x: 0, + y: 0, + upSpeed: "", + downSpeed: "", + timestamp: "", + visible: false, + dataIndex: -1, + highlightY: 0, + }); - // Canvas引用和渲染状态 - const canvasRef = useRef(null); - const animationFrameRef = useRef(undefined); - const lastRenderTimeRef = useRef(0); - const isInitializedRef = useRef(false); + // Canvas引用和渲染状态 + const canvasRef = useRef(null); + const animationFrameRef = useRef(undefined); + const lastRenderTimeRef = useRef(0); + const isInitializedRef = useRef(false); - // 当前显示的数据缓存 - const [displayData, setDisplayData] = useState([]); + // 当前显示的数据缓存 + const [displayData, setDisplayData] = useState([]); - // 主题颜色配置 - const colors = useMemo( - () => ({ - up: theme.palette.secondary.main, - down: theme.palette.primary.main, - grid: theme.palette.divider, - text: theme.palette.text.secondary, - background: theme.palette.background.paper, - }), - [theme], - ); + // 主题颜色配置 + const colors = useMemo( + () => ({ + up: theme.palette.secondary.main, + down: theme.palette.primary.main, + grid: theme.palette.divider, + text: theme.palette.text.secondary, + background: theme.palette.background.paper, + }), + [theme], + ); - // 更新显示数据(防抖处理) - const updateDisplayDataDebounced = useMemo(() => { - let timeoutId: number; - return (newData: ITrafficDataPoint[]) => { - clearTimeout(timeoutId); - timeoutId = window.setTimeout(() => { - setDisplayData(newData); - }, 50); // 50ms防抖 - }; - }, []); - - // 监听数据变化 - useEffect(() => { - const timeRangeData = getDataForTimeRange(timeRange); - updateDisplayDataDebounced(timeRangeData); - }, [dataPoints, timeRange, getDataForTimeRange, updateDisplayDataDebounced]); - - // Y轴坐标计算 - 基于刻度范围的线性映射 - const calculateY = useCallback( - (value: number, height: number, data: ITrafficDataPoint[]): number => { - const padding = GRAPH_CONFIG.padding; - const topY = padding.top + 10; // 与刻度系统保持一致 - const bottomY = height - padding.bottom - 5; - - if (data.length === 0) return bottomY; - - // 获取当前的刻度范围 - const allValues = [...data.map((d) => d.up), ...data.map((d) => d.down)]; - const maxValue = Math.max(...allValues); - const minValue = Math.min(...allValues); - - let topValue, bottomValue; - - if (maxValue === 0) { - topValue = 1024; - bottomValue = 0; - } else { - const range = maxValue - minValue; - const padding_percent = range > 0 ? 0.1 : 0.5; - - if (range === 0) { - bottomValue = 0; - topValue = maxValue * 1.2; - } else { - bottomValue = Math.max(0, minValue - range * padding_percent); - topValue = maxValue + range * padding_percent; - } - } - - // 线性映射到Y坐标 - if (topValue === bottomValue) return bottomY; - - const ratio = (value - bottomValue) / (topValue - bottomValue); - return bottomY - ratio * (bottomY - topY); - }, - [], - ); - - // 鼠标悬浮处理 - 计算最近的数据点 - const handleMouseMove = useCallback( - (event: React.MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas || displayData.length === 0) return; - - const rect = canvas.getBoundingClientRect(); - const mouseX = event.clientX - rect.left; - const mouseY = event.clientY - rect.top; - - const padding = GRAPH_CONFIG.padding; - const effectiveWidth = rect.width - padding.left - padding.right; - - // 计算最接近的数据点索引 - const relativeMouseX = mouseX - padding.left; - const ratio = Math.max(0, Math.min(1, relativeMouseX / effectiveWidth)); - const dataIndex = Math.round(ratio * (displayData.length - 1)); - - if (dataIndex >= 0 && dataIndex < displayData.length) { - const dataPoint = displayData[dataIndex]; - - // 格式化流量数据 - const [upValue, upUnit] = parseTraffic(dataPoint.up); - const [downValue, downUnit] = parseTraffic(dataPoint.down); - - // 格式化时间戳 - const timeStr = dataPoint.timestamp - ? new Date(dataPoint.timestamp).toLocaleTimeString("zh-CN", { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }) - : "未知时间"; - - // 计算数据点对应的Y坐标位置(用于高亮) - const upY = calculateY(dataPoint.up, rect.height, displayData); - const downY = calculateY(dataPoint.down, rect.height, displayData); - const highlightY = - Math.max(dataPoint.up, dataPoint.down) === dataPoint.up ? upY : downY; - - setTooltipData({ - x: mouseX, - y: mouseY, - upSpeed: `${upValue}${upUnit}/s`, - downSpeed: `${downValue}${downUnit}/s`, - timestamp: timeStr, - visible: true, - dataIndex, - highlightY, - }); - } - }, - [displayData, calculateY], - ); - - // 鼠标离开处理 - const handleMouseLeave = useCallback(() => { - setTooltipData((prev) => ({ ...prev, visible: false })); - }, []); - - // 获取智能Y轴刻度(三刻度系统:最小值、中间值、最大值) - const getYAxisTicks = useCallback( - (data: ITrafficDataPoint[], height: number) => { - if (data.length === 0) return []; - - // 找到数据的最大值和最小值 - const allValues = [...data.map((d) => d.up), ...data.map((d) => d.down)]; - const maxValue = Math.max(...allValues); - const minValue = Math.min(...allValues); - - // 格式化流量数值 - const formatTrafficValue = (bytes: number): string => { - if (bytes === 0) return "0"; - if (bytes < 1024) return `${Math.round(bytes)}B`; - if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; + // 更新显示数据(防抖处理) + const updateDisplayDataDebounced = useMemo(() => { + let timeoutId: number; + return (newData: ITrafficDataPoint[]) => { + clearTimeout(timeoutId); + timeoutId = window.setTimeout(() => { + setDisplayData(newData); + }, 50); // 50ms防抖 }; + }, []); - const padding = GRAPH_CONFIG.padding; + // 监听数据变化 + useEffect(() => { + const timeRangeData = getDataForTimeRange(timeRange); + updateDisplayDataDebounced(timeRangeData); + }, [ + dataPoints, + timeRange, + getDataForTimeRange, + updateDisplayDataDebounced, + ]); - // 强制显示三个刻度:底部、中间、顶部 - const topY = padding.top + 10; // 避免与顶部时间范围按钮重叠 - const bottomY = height - padding.bottom - 5; // 避免与底部时间轴重叠 - const middleY = (topY + bottomY) / 2; + // Y轴坐标计算 - 基于刻度范围的线性映射 + const calculateY = useCallback( + (value: number, height: number, data: ITrafficDataPoint[]): number => { + const padding = GRAPH_CONFIG.padding; + const topY = padding.top + 10; // 与刻度系统保持一致 + const bottomY = height - padding.bottom - 5; - // 计算对应的值 - let topValue, middleValue, bottomValue; + if (data.length === 0) return bottomY; - if (maxValue === 0) { - // 如果没有流量,显示0到一个小值的范围 - topValue = 1024; // 1KB - middleValue = 512; // 512B - bottomValue = 0; - } else { - // 根据数据范围计算合适的刻度值 - const range = maxValue - minValue; - const padding_percent = range > 0 ? 0.1 : 0.5; // 如果范围为0,使用更大的边距 + // 获取当前的刻度范围 + const allValues = [ + ...data.map((d) => d.up), + ...data.map((d) => d.down), + ]; + const maxValue = Math.max(...allValues); + const minValue = Math.min(...allValues); - if (range === 0) { - // 所有值相同的情况 + let topValue, bottomValue; + + if (maxValue === 0) { + topValue = 1024; bottomValue = 0; - middleValue = maxValue * 0.5; - topValue = maxValue * 1.2; } else { - // 正常情况 - bottomValue = Math.max(0, minValue - range * padding_percent); - topValue = maxValue + range * padding_percent; - middleValue = (bottomValue + topValue) / 2; + const range = maxValue - minValue; + const padding_percent = range > 0 ? 0.1 : 0.5; + + if (range === 0) { + bottomValue = 0; + topValue = maxValue * 1.2; + } else { + bottomValue = Math.max(0, minValue - range * padding_percent); + topValue = maxValue + range * padding_percent; + } } - } - // 创建三个固定位置的刻度 - const ticks = [ - { - value: bottomValue, - label: formatTrafficValue(bottomValue), - y: bottomY, - }, - { - value: middleValue, - label: formatTrafficValue(middleValue), - y: middleY, - }, - { - value: topValue, - label: formatTrafficValue(topValue), - y: topY, - }, - ]; + // 线性映射到Y坐标 + if (topValue === bottomValue) return bottomY; - return ticks; - }, - [], - ); + const ratio = (value - bottomValue) / (topValue - bottomValue); + return bottomY - ratio * (bottomY - topY); + }, + [], + ); - // 绘制Y轴刻度线和标签 - const drawYAxis = useCallback( - ( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - data: ITrafficDataPoint[], - ) => { - const padding = GRAPH_CONFIG.padding; - const ticks = getYAxisTicks(data, height); + // 鼠标悬浮处理 - 计算最近的数据点 + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas || displayData.length === 0) return; - if (ticks.length === 0) return; + const rect = canvas.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; - ctx.save(); + const padding = GRAPH_CONFIG.padding; + const effectiveWidth = rect.width - padding.left - padding.right; - ticks.forEach((tick, index) => { - const isBottomTick = index === 0; // 最底部的刻度 - const isTopTick = index === ticks.length - 1; // 最顶部的刻度 + // 计算最接近的数据点索引 + const relativeMouseX = mouseX - padding.left; + const ratio = Math.max(0, Math.min(1, relativeMouseX / effectiveWidth)); + const dataIndex = Math.round(ratio * (displayData.length - 1)); - // 绘制水平刻度线,只绘制关键刻度线 - if (isBottomTick || isTopTick) { - ctx.strokeStyle = colors.grid; - ctx.lineWidth = isBottomTick ? 0.8 : 0.4; // 底部刻度线稍粗 - ctx.globalAlpha = isBottomTick ? 0.25 : 0.15; + if (dataIndex >= 0 && dataIndex < displayData.length) { + const dataPoint = displayData[dataIndex]; + // 格式化流量数据 + const [upValue, upUnit] = parseTraffic(dataPoint.up); + const [downValue, downUnit] = parseTraffic(dataPoint.down); + + // 格式化时间戳 + const timeStr = dataPoint.timestamp + ? new Date(dataPoint.timestamp).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + : "未知时间"; + + // 计算数据点对应的Y坐标位置(用于高亮) + const upY = calculateY(dataPoint.up, rect.height, displayData); + const downY = calculateY(dataPoint.down, rect.height, displayData); + const highlightY = + Math.max(dataPoint.up, dataPoint.down) === dataPoint.up + ? upY + : downY; + + setTooltipData({ + x: mouseX, + y: mouseY, + upSpeed: `${upValue}${upUnit}/s`, + downSpeed: `${downValue}${downUnit}/s`, + timestamp: timeStr, + visible: true, + dataIndex, + highlightY, + }); + } + }, + [displayData, calculateY], + ); + + // 鼠标离开处理 + const handleMouseLeave = useCallback(() => { + setTooltipData((prev) => ({ ...prev, visible: false })); + }, []); + + // 获取智能Y轴刻度(三刻度系统:最小值、中间值、最大值) + const getYAxisTicks = useCallback( + (data: ITrafficDataPoint[], height: number) => { + if (data.length === 0) return []; + + // 找到数据的最大值和最小值 + const allValues = [ + ...data.map((d) => d.up), + ...data.map((d) => d.down), + ]; + const maxValue = Math.max(...allValues); + const minValue = Math.min(...allValues); + + // 格式化流量数值 + const formatTrafficValue = (bytes: number): string => { + if (bytes === 0) return "0"; + if (bytes < 1024) return `${Math.round(bytes)}B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; + }; + + const padding = GRAPH_CONFIG.padding; + + // 强制显示三个刻度:底部、中间、顶部 + const topY = padding.top + 10; // 避免与顶部时间范围按钮重叠 + const bottomY = height - padding.bottom - 5; // 避免与底部时间轴重叠 + const middleY = (topY + bottomY) / 2; + + // 计算对应的值 + let topValue, middleValue, bottomValue; + + if (maxValue === 0) { + // 如果没有流量,显示0到一个小值的范围 + topValue = 1024; // 1KB + middleValue = 512; // 512B + bottomValue = 0; + } else { + // 根据数据范围计算合适的刻度值 + const range = maxValue - minValue; + const padding_percent = range > 0 ? 0.1 : 0.5; // 如果范围为0,使用更大的边距 + + if (range === 0) { + // 所有值相同的情况 + bottomValue = 0; + middleValue = maxValue * 0.5; + topValue = maxValue * 1.2; + } else { + // 正常情况 + bottomValue = Math.max(0, minValue - range * padding_percent); + topValue = maxValue + range * padding_percent; + middleValue = (bottomValue + topValue) / 2; + } + } + + // 创建三个固定位置的刻度 + const ticks = [ + { + value: bottomValue, + label: formatTrafficValue(bottomValue), + y: bottomY, + }, + { + value: middleValue, + label: formatTrafficValue(middleValue), + y: middleY, + }, + { + value: topValue, + label: formatTrafficValue(topValue), + y: topY, + }, + ]; + + return ticks; + }, + [], + ); + + // 绘制Y轴刻度线和标签 + const drawYAxis = useCallback( + ( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + data: ITrafficDataPoint[], + ) => { + const padding = GRAPH_CONFIG.padding; + const ticks = getYAxisTicks(data, height); + + if (ticks.length === 0) return; + + ctx.save(); + + ticks.forEach((tick, index) => { + const isBottomTick = index === 0; // 最底部的刻度 + const isTopTick = index === ticks.length - 1; // 最顶部的刻度 + + // 绘制水平刻度线,只绘制关键刻度线 + if (isBottomTick || isTopTick) { + ctx.strokeStyle = colors.grid; + ctx.lineWidth = isBottomTick ? 0.8 : 0.4; // 底部刻度线稍粗 + ctx.globalAlpha = isBottomTick ? 0.25 : 0.15; + + ctx.beginPath(); + ctx.moveTo(padding.left, tick.y); + ctx.lineTo(width - padding.right, tick.y); + ctx.stroke(); + } + + // 绘制Y轴标签 + ctx.fillStyle = colors.text; + ctx.font = + "8px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"; + ctx.globalAlpha = 0.9; + ctx.textAlign = "right"; + ctx.textBaseline = "middle"; + + // 为标签添加更清晰的背景(仅在必要时) + if (tick.label !== "0") { + const labelWidth = ctx.measureText(tick.label).width; + ctx.globalAlpha = 0.15; + ctx.fillStyle = colors.background; + ctx.fillRect( + padding.left - labelWidth - 8, + tick.y - 5, + labelWidth + 4, + 10, + ); + } + + // 绘制标签文字 + ctx.globalAlpha = 0.9; + ctx.fillStyle = colors.text; + ctx.fillText(tick.label, padding.left - 4, tick.y); + }); + + ctx.restore(); + }, + [colors.grid, colors.text, colors.background, getYAxisTicks], + ); + + // 获取时间范围对应的最佳时间显示策略 + const getTimeDisplayStrategy = useCallback( + (timeRangeMinutes: TimeRange) => { + switch (timeRangeMinutes) { + case 1: // 1分钟:更密集的时间标签,显示 MM:SS + return { + maxLabels: 6, // 减少到6个,更适合短时间 + formatTime: (timestamp: number) => { + const date = new Date(timestamp); + const minutes = date.getMinutes().toString().padStart(2, "0"); + const seconds = date.getSeconds().toString().padStart(2, "0"); + return `${minutes}:${seconds}`; // 显示 MM:SS + }, + intervalSeconds: 10, // 每10秒一个标签,更合理 + minPixelDistance: 35, // 减少间距,允许更多标签 + }; + case 5: // 5分钟:中等密度,显示 HH:MM + return { + maxLabels: 6, // 6个标签比较合适 + formatTime: (timestamp: number) => { + const date = new Date(timestamp); + return date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + }); // 显示 HH:MM + }, + intervalSeconds: 30, // 约30秒间隔 + minPixelDistance: 38, // 减少间距,允许更多标签 + }; + case 10: // 10分钟:标准密度,显示 HH:MM + default: + return { + maxLabels: 8, // 保持8个 + formatTime: (timestamp: number) => { + const date = new Date(timestamp); + return date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + }); // 显示 HH:MM + }, + intervalSeconds: 60, // 1分钟间隔 + minPixelDistance: 40, // 减少间距,允许更多标签 + }; + } + }, + [], + ); + + // 绘制时间轴 + const drawTimeAxis = useCallback( + ( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + data: ITrafficDataPoint[], + ) => { + if (data.length === 0) return; + + const padding = GRAPH_CONFIG.padding; + const effectiveWidth = width - padding.left - padding.right; + const timeAxisY = height - padding.bottom + 14; + + const strategy = getTimeDisplayStrategy(timeRange); + + ctx.save(); + ctx.fillStyle = colors.text; + ctx.font = + "10px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"; + ctx.globalAlpha = 0.7; + + // 根据数据长度和时间范围智能选择显示间隔 + const targetLabels = Math.min(strategy.maxLabels, data.length); + const step = Math.max(1, Math.floor(data.length / (targetLabels - 1))); + + // 使用策略中定义的最小像素间距 + const minPixelDistance = strategy.minPixelDistance || 45; + const actualStep = Math.max( + step, + Math.ceil((data.length * minPixelDistance) / effectiveWidth), + ); + + // 收集要显示的时间点 + const timePoints: Array<{ index: number; x: number; label: string }> = + []; + + // 添加第一个时间点 + if (data.length > 0 && data[0].timestamp) { + timePoints.push({ + index: 0, + x: padding.left, + label: strategy.formatTime(data[0].timestamp), + }); + } + + // 添加中间的时间点 + for ( + let i = actualStep; + i < data.length - actualStep; + i += actualStep + ) { + const point = data[i]; + if (!point.timestamp) continue; + + const x = padding.left + (i / (data.length - 1)) * effectiveWidth; + timePoints.push({ + index: i, + x, + label: strategy.formatTime(point.timestamp), + }); + } + + // 添加最后一个时间点(如果不会与前面的重叠) + if (data.length > 1 && data[data.length - 1].timestamp) { + const lastX = width - padding.right; + const lastPoint = timePoints[timePoints.length - 1]; + + // 确保最后一个标签与前一个标签有足够间距 + if (!lastPoint || lastX - lastPoint.x >= minPixelDistance) { + timePoints.push({ + index: data.length - 1, + x: lastX, + label: strategy.formatTime(data[data.length - 1].timestamp), + }); + } + } + + // 绘制时间标签 + timePoints.forEach((point, index) => { + if (index === 0) { + // 第一个标签左对齐 + ctx.textAlign = "left"; + } else if (index === timePoints.length - 1) { + // 最后一个标签右对齐 + ctx.textAlign = "right"; + } else { + // 中间标签居中对齐 + ctx.textAlign = "center"; + } + + ctx.fillText(point.label, point.x, timeAxisY); + }); + + ctx.restore(); + }, + [colors.text, timeRange, getTimeDisplayStrategy], + ); + + // 绘制网格线 + const drawGrid = useCallback( + (ctx: CanvasRenderingContext2D, width: number, height: number) => { + const padding = GRAPH_CONFIG.padding; + const effectiveWidth = width - padding.left - padding.right; + const effectiveHeight = height - padding.top - padding.bottom; + + ctx.save(); + ctx.strokeStyle = colors.grid; + ctx.lineWidth = GRAPH_CONFIG.lineWidth.grid; + ctx.globalAlpha = 0.7; + + // 水平网格线 + const horizontalLines = 4; + for (let i = 1; i <= horizontalLines; i++) { + const y = padding.top + (effectiveHeight / (horizontalLines + 1)) * i; ctx.beginPath(); - ctx.moveTo(padding.left, tick.y); - ctx.lineTo(width - padding.right, tick.y); + ctx.moveTo(padding.left, y); + ctx.lineTo(width - padding.right, y); ctx.stroke(); } - // 绘制Y轴标签 - ctx.fillStyle = colors.text; - ctx.font = - "8px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"; - ctx.globalAlpha = 0.9; - ctx.textAlign = "right"; - ctx.textBaseline = "middle"; + // 垂直网格线 + const verticalLines = 6; + for (let i = 1; i <= verticalLines; i++) { + const x = padding.left + (effectiveWidth / (verticalLines + 1)) * i; + ctx.beginPath(); + ctx.moveTo(x, padding.top); + ctx.lineTo(x, height - padding.bottom); + ctx.stroke(); + } - // 为标签添加更清晰的背景(仅在必要时) - if (tick.label !== "0") { - const labelWidth = ctx.measureText(tick.label).width; - ctx.globalAlpha = 0.15; - ctx.fillStyle = colors.background; - ctx.fillRect( - padding.left - labelWidth - 8, - tick.y - 5, - labelWidth + 4, - 10, + ctx.restore(); + }, + [colors.grid], + ); + + // 绘制流量线条 + const drawTrafficLine = useCallback( + ( + ctx: CanvasRenderingContext2D, + values: number[], + width: number, + height: number, + color: string, + withGradient = false, + data: ITrafficDataPoint[], + ) => { + if (values.length < 2) return; + + const padding = GRAPH_CONFIG.padding; + const effectiveWidth = width - padding.left - padding.right; + + const points = values.map((value, index) => [ + padding.left + (index / (values.length - 1)) * effectiveWidth, + calculateY(value, height, data), + ]); + + ctx.save(); + + // 绘制渐变填充 + if (withGradient && chartStyle === "bezier") { + const gradient = ctx.createLinearGradient( + 0, + padding.top, + 0, + height - padding.bottom, ); + gradient.addColorStop( + 0, + `${color}${Math.round(GRAPH_CONFIG.alpha.gradient * 255) + .toString(16) + .padStart(2, "0")}`, + ); + gradient.addColorStop(1, `${color}00`); + + ctx.beginPath(); + ctx.moveTo(points[0][0], points[0][1]); + + if (chartStyle === "bezier") { + for (let i = 1; i < points.length; i++) { + const current = points[i]; + const next = points[i + 1] || current; + const controlX = (current[0] + next[0]) / 2; + const controlY = (current[1] + next[1]) / 2; + ctx.quadraticCurveTo(current[0], current[1], controlX, controlY); + } + } else { + for (let i = 1; i < points.length; i++) { + ctx.lineTo(points[i][0], points[i][1]); + } + } + + ctx.lineTo(points[points.length - 1][0], height - padding.bottom); + ctx.lineTo(points[0][0], height - padding.bottom); + ctx.closePath(); + ctx.fillStyle = gradient; + ctx.fill(); } - // 绘制标签文字 - ctx.globalAlpha = 0.9; - ctx.fillStyle = colors.text; - ctx.fillText(tick.label, padding.left - 4, tick.y); - }); - - ctx.restore(); - }, - [colors.grid, colors.text, colors.background, getYAxisTicks], - ); - - // 获取时间范围对应的最佳时间显示策略 - const getTimeDisplayStrategy = useCallback((timeRangeMinutes: TimeRange) => { - switch (timeRangeMinutes) { - case 1: // 1分钟:更密集的时间标签,显示 MM:SS - return { - maxLabels: 6, // 减少到6个,更适合短时间 - formatTime: (timestamp: number) => { - const date = new Date(timestamp); - const minutes = date.getMinutes().toString().padStart(2, "0"); - const seconds = date.getSeconds().toString().padStart(2, "0"); - return `${minutes}:${seconds}`; // 显示 MM:SS - }, - intervalSeconds: 10, // 每10秒一个标签,更合理 - minPixelDistance: 35, // 减少间距,允许更多标签 - }; - case 5: // 5分钟:中等密度,显示 HH:MM - return { - maxLabels: 6, // 6个标签比较合适 - formatTime: (timestamp: number) => { - const date = new Date(timestamp); - return date.toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - }); // 显示 HH:MM - }, - intervalSeconds: 30, // 约30秒间隔 - minPixelDistance: 38, // 减少间距,允许更多标签 - }; - case 10: // 10分钟:标准密度,显示 HH:MM - default: - return { - maxLabels: 8, // 保持8个 - formatTime: (timestamp: number) => { - const date = new Date(timestamp); - return date.toLocaleTimeString("en-US", { - hour12: false, - hour: "2-digit", - minute: "2-digit", - }); // 显示 HH:MM - }, - intervalSeconds: 60, // 1分钟间隔 - minPixelDistance: 40, // 减少间距,允许更多标签 - }; - } - }, []); - - // 绘制时间轴 - const drawTimeAxis = useCallback( - ( - ctx: CanvasRenderingContext2D, - width: number, - height: number, - data: ITrafficDataPoint[], - ) => { - if (data.length === 0) return; - - const padding = GRAPH_CONFIG.padding; - const effectiveWidth = width - padding.left - padding.right; - const timeAxisY = height - padding.bottom + 14; - - const strategy = getTimeDisplayStrategy(timeRange); - - ctx.save(); - ctx.fillStyle = colors.text; - ctx.font = - "10px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif"; - ctx.globalAlpha = 0.7; - - // 根据数据长度和时间范围智能选择显示间隔 - const targetLabels = Math.min(strategy.maxLabels, data.length); - const step = Math.max(1, Math.floor(data.length / (targetLabels - 1))); - - // 使用策略中定义的最小像素间距 - const minPixelDistance = strategy.minPixelDistance || 45; - const actualStep = Math.max( - step, - Math.ceil((data.length * minPixelDistance) / effectiveWidth), - ); - - // 收集要显示的时间点 - const timePoints: Array<{ index: number; x: number; label: string }> = []; - - // 添加第一个时间点 - if (data.length > 0 && data[0].timestamp) { - timePoints.push({ - index: 0, - x: padding.left, - label: strategy.formatTime(data[0].timestamp), - }); - } - - // 添加中间的时间点 - for (let i = actualStep; i < data.length - actualStep; i += actualStep) { - const point = data[i]; - if (!point.timestamp) continue; - - const x = padding.left + (i / (data.length - 1)) * effectiveWidth; - timePoints.push({ - index: i, - x, - label: strategy.formatTime(point.timestamp), - }); - } - - // 添加最后一个时间点(如果不会与前面的重叠) - if (data.length > 1 && data[data.length - 1].timestamp) { - const lastX = width - padding.right; - const lastPoint = timePoints[timePoints.length - 1]; - - // 确保最后一个标签与前一个标签有足够间距 - if (!lastPoint || lastX - lastPoint.x >= minPixelDistance) { - timePoints.push({ - index: data.length - 1, - x: lastX, - label: strategy.formatTime(data[data.length - 1].timestamp), - }); - } - } - - // 绘制时间标签 - timePoints.forEach((point, index) => { - if (index === 0) { - // 第一个标签左对齐 - ctx.textAlign = "left"; - } else if (index === timePoints.length - 1) { - // 最后一个标签右对齐 - ctx.textAlign = "right"; - } else { - // 中间标签居中对齐 - ctx.textAlign = "center"; - } - - ctx.fillText(point.label, point.x, timeAxisY); - }); - - ctx.restore(); - }, - [colors.text, timeRange, getTimeDisplayStrategy], - ); - - // 绘制网格线 - const drawGrid = useCallback( - (ctx: CanvasRenderingContext2D, width: number, height: number) => { - const padding = GRAPH_CONFIG.padding; - const effectiveWidth = width - padding.left - padding.right; - const effectiveHeight = height - padding.top - padding.bottom; - - ctx.save(); - ctx.strokeStyle = colors.grid; - ctx.lineWidth = GRAPH_CONFIG.lineWidth.grid; - ctx.globalAlpha = 0.7; - - // 水平网格线 - const horizontalLines = 4; - for (let i = 1; i <= horizontalLines; i++) { - const y = padding.top + (effectiveHeight / (horizontalLines + 1)) * i; + // 绘制主线条 ctx.beginPath(); - ctx.moveTo(padding.left, y); - ctx.lineTo(width - padding.right, y); - ctx.stroke(); - } + ctx.strokeStyle = color; + ctx.lineWidth = GRAPH_CONFIG.lineWidth.up; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.globalAlpha = GRAPH_CONFIG.alpha.line; - // 垂直网格线 - const verticalLines = 6; - for (let i = 1; i <= verticalLines; i++) { - const x = padding.left + (effectiveWidth / (verticalLines + 1)) * i; - ctx.beginPath(); - ctx.moveTo(x, padding.top); - ctx.lineTo(x, height - padding.bottom); - ctx.stroke(); - } - - ctx.restore(); - }, - [colors.grid], - ); - - // 绘制流量线条 - const drawTrafficLine = useCallback( - ( - ctx: CanvasRenderingContext2D, - values: number[], - width: number, - height: number, - color: string, - withGradient = false, - data: ITrafficDataPoint[], - ) => { - if (values.length < 2) return; - - const padding = GRAPH_CONFIG.padding; - const effectiveWidth = width - padding.left - padding.right; - - const points = values.map((value, index) => [ - padding.left + (index / (values.length - 1)) * effectiveWidth, - calculateY(value, height, data), - ]); - - ctx.save(); - - // 绘制渐变填充 - if (withGradient && chartStyle === "bezier") { - const gradient = ctx.createLinearGradient( - 0, - padding.top, - 0, - height - padding.bottom, - ); - gradient.addColorStop( - 0, - `${color}${Math.round(GRAPH_CONFIG.alpha.gradient * 255) - .toString(16) - .padStart(2, "0")}`, - ); - gradient.addColorStop(1, `${color}00`); - - ctx.beginPath(); ctx.moveTo(points[0][0], points[0][1]); if (chartStyle === "bezier") { @@ -615,359 +669,337 @@ export const EnhancedCanvasTrafficGraph = memo(({ ref, ...props }) => { } } - ctx.lineTo(points[points.length - 1][0], height - padding.bottom); - ctx.lineTo(points[0][0], height - padding.bottom); - ctx.closePath(); - ctx.fillStyle = gradient; - ctx.fill(); + ctx.stroke(); + ctx.restore(); + }, + [calculateY, chartStyle], + ); + + // 主绘制函数 + const drawGraph = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || displayData.length === 0) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Canvas尺寸设置 + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const width = rect.width; + const height = rect.height; + + // 只在尺寸变化时重新设置Canvas + if (canvas.width !== width * dpr || canvas.height !== height * dpr) { + canvas.width = width * dpr; + canvas.height = height * dpr; + ctx.scale(dpr, dpr); + canvas.style.width = width + "px"; + canvas.style.height = height + "px"; } - // 绘制主线条 - ctx.beginPath(); - ctx.strokeStyle = color; - ctx.lineWidth = GRAPH_CONFIG.lineWidth.up; - ctx.lineCap = "round"; - ctx.lineJoin = "round"; - ctx.globalAlpha = GRAPH_CONFIG.alpha.line; + // 清空画布 + ctx.clearRect(0, 0, width, height); - ctx.moveTo(points[0][0], points[0][1]); + // 绘制Y轴刻度线(背景层) + drawYAxis(ctx, width, height, displayData); - if (chartStyle === "bezier") { - for (let i = 1; i < points.length; i++) { - const current = points[i]; - const next = points[i + 1] || current; - const controlX = (current[0] + next[0]) / 2; - const controlY = (current[1] + next[1]) / 2; - ctx.quadraticCurveTo(current[0], current[1], controlX, controlY); - } - } else { - for (let i = 1; i < points.length; i++) { - ctx.lineTo(points[i][0], points[i][1]); - } + // 绘制网格 + drawGrid(ctx, width, height); + + // 绘制时间轴 + drawTimeAxis(ctx, width, height, displayData); + + // 提取流量数据 + const upValues = displayData.map((d) => d.up); + const downValues = displayData.map((d) => d.down); + + // 绘制下载线(背景层) + drawTrafficLine( + ctx, + downValues, + width, + height, + colors.down, + true, + displayData, + ); + + // 绘制上传线(前景层) + drawTrafficLine( + ctx, + upValues, + width, + height, + colors.up, + true, + displayData, + ); + + // 绘制悬浮高亮线 + if (tooltipData.visible && tooltipData.dataIndex >= 0) { + const padding = GRAPH_CONFIG.padding; + const effectiveWidth = width - padding.left - padding.right; + const dataX = + padding.left + + (tooltipData.dataIndex / (displayData.length - 1)) * effectiveWidth; + + ctx.save(); + ctx.strokeStyle = colors.text; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.6; + ctx.setLineDash([4, 4]); // 虚线效果 + + // 绘制垂直指示线 + ctx.beginPath(); + ctx.moveTo(dataX, padding.top); + ctx.lineTo(dataX, height - padding.bottom); + ctx.stroke(); + + // 绘制水平指示线(高亮Y轴位置) + ctx.beginPath(); + ctx.moveTo(padding.left, tooltipData.highlightY); + ctx.lineTo(width - padding.right, tooltipData.highlightY); + ctx.stroke(); + + ctx.restore(); } - ctx.stroke(); - ctx.restore(); - }, - [calculateY, chartStyle], - ); - - // 主绘制函数 - const drawGraph = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas || displayData.length === 0) return; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - // Canvas尺寸设置 - const rect = canvas.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - const width = rect.width; - const height = rect.height; - - // 只在尺寸变化时重新设置Canvas - if (canvas.width !== width * dpr || canvas.height !== height * dpr) { - canvas.width = width * dpr; - canvas.height = height * dpr; - ctx.scale(dpr, dpr); - canvas.style.width = width + "px"; - canvas.style.height = height + "px"; - } - - // 清空画布 - ctx.clearRect(0, 0, width, height); - - // 绘制Y轴刻度线(背景层) - drawYAxis(ctx, width, height, displayData); - - // 绘制网格 - drawGrid(ctx, width, height); - - // 绘制时间轴 - drawTimeAxis(ctx, width, height, displayData); - - // 提取流量数据 - const upValues = displayData.map((d) => d.up); - const downValues = displayData.map((d) => d.down); - - // 绘制下载线(背景层) - drawTrafficLine( - ctx, - downValues, - width, - height, - colors.down, - true, + isInitializedRef.current = true; + }, [ displayData, + colors, + drawYAxis, + drawGrid, + drawTimeAxis, + drawTrafficLine, + tooltipData, + ]); + + // 受控的动画循环 + useEffect(() => { + const animate = (currentTime: number) => { + // 控制帧率,减少不必要的重绘 + if ( + currentTime - lastRenderTimeRef.current >= + 1000 / GRAPH_CONFIG.targetFPS + ) { + drawGraph(); + lastRenderTimeRef.current = currentTime; + } + animationFrameRef.current = requestAnimationFrame(animate); + }; + + // 只有在有数据时才开始动画 + if (displayData.length > 0) { + animationFrameRef.current = requestAnimationFrame(animate); + } + + return () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, [drawGraph, displayData.length]); + + // 切换时间范围 + const handleTimeRangeClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + setTimeRange((prev) => { + return prev === 1 ? 5 : prev === 5 ? 10 : 1; + }); + }, []); + + // 切换图表样式 + const toggleStyle = useCallback(() => { + setChartStyle((prev) => (prev === "bezier" ? "line" : "bezier")); + }, []); + + // 兼容性方法 + const appendData = useCallback((data: ITrafficItem) => { + console.log( + "[EnhancedCanvasTrafficGraphV2] appendData called (using global data):", + data, + ); + }, []); + + // 暴露方法给父组件 + useImperativeHandle( + ref, + () => ({ + appendData, + toggleStyle, + }), + [appendData, toggleStyle], ); - // 绘制上传线(前景层) - drawTrafficLine(ctx, upValues, width, height, colors.up, true, displayData); + // 获取时间范围文本 + const getTimeRangeText = useCallback(() => { + return t("{{time}} Minutes", { time: timeRange }); + }, [timeRange, t]); - // 绘制悬浮高亮线 - if (tooltipData.visible && tooltipData.dataIndex >= 0) { - const padding = GRAPH_CONFIG.padding; - const effectiveWidth = width - padding.left - padding.right; - const dataX = - padding.left + - (tooltipData.dataIndex / (displayData.length - 1)) * effectiveWidth; - - ctx.save(); - ctx.strokeStyle = colors.text; - ctx.lineWidth = 1; - ctx.globalAlpha = 0.6; - ctx.setLineDash([4, 4]); // 虚线效果 - - // 绘制垂直指示线 - ctx.beginPath(); - ctx.moveTo(dataX, padding.top); - ctx.lineTo(dataX, height - padding.bottom); - ctx.stroke(); - - // 绘制水平指示线(高亮Y轴位置) - ctx.beginPath(); - ctx.moveTo(padding.left, tooltipData.highlightY); - ctx.lineTo(width - padding.right, tooltipData.highlightY); - ctx.stroke(); - - ctx.restore(); - } - - isInitializedRef.current = true; - }, [ - displayData, - colors, - drawYAxis, - drawGrid, - drawTimeAxis, - drawTrafficLine, - tooltipData, - ]); - - // 受控的动画循环 - useEffect(() => { - const animate = (currentTime: number) => { - // 控制帧率,减少不必要的重绘 - if ( - currentTime - lastRenderTimeRef.current >= - 1000 / GRAPH_CONFIG.targetFPS - ) { - drawGraph(); - lastRenderTimeRef.current = currentTime; - } - animationFrameRef.current = requestAnimationFrame(animate); - }; - - // 只有在有数据时才开始动画 - if (displayData.length > 0) { - animationFrameRef.current = requestAnimationFrame(animate); - } - - return () => { - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - } - }; - }, [drawGraph, displayData.length]); - - // 切换时间范围 - const handleTimeRangeClick = useCallback((event: React.MouseEvent) => { - event.stopPropagation(); - setTimeRange((prev) => { - return prev === 1 ? 5 : prev === 5 ? 10 : 1; - }); - }, []); - - // 切换图表样式 - const toggleStyle = useCallback(() => { - setChartStyle((prev) => (prev === "bezier" ? "line" : "bezier")); - }, []); - - // 兼容性方法 - const appendData = useCallback((data: ITrafficItem) => { - console.log( - "[EnhancedCanvasTrafficGraphV2] appendData called (using global data):", - data, - ); - }, []); - - // 暴露方法给父组件 - useImperativeHandle( - ref, - () => ({ - appendData, - toggleStyle, - }), - [appendData, toggleStyle], - ); - - // 获取时间范围文本 - const getTimeRangeText = useCallback(() => { - return t("{{time}} Minutes", { time: timeRange }); - }, [timeRange, t]); - - return ( - - - - {/* 控制层覆盖 */} + return ( - {/* 时间范围按钮 */} - - {getTimeRangeText()} - + onMouseMove={handleMouseMove} + onMouseLeave={handleMouseLeave} + /> - {/* 图例 */} + {/* 控制层覆盖 */} + {/* 时间范围按钮 */} - {t("Upload")} + {getTimeRangeText()} - - {t("Download")} - - - {/* 样式指示器 */} - - {chartStyle === "bezier" ? "Smooth" : "Linear"} - - - {/* 数据统计指示器(左下角) */} - - Points: {displayData.length} | Fresh: {isDataFresh ? "✓" : "✗"} | - Compressed: {samplerStats.compressedBufferSize} - - - {/* 悬浮提示框 */} - {tooltipData.visible && ( + {/* 图例 */} 200 ? "translateX(-100%)" : "translateX(0)", - boxShadow: "0 4px 12px rgba(0,0,0,0.15)", - backdropFilter: "none", - opacity: 1, + top: 6, + right: 8, + display: "flex", + flexDirection: "column", + gap: 0.5, }} > - - {tooltipData.timestamp} + + {t("Upload")} - - ↑ {tooltipData.upSpeed} - - - ↓ {tooltipData.downSpeed} + + {t("Download")} - )} + + {/* 样式指示器 */} + + {chartStyle === "bezier" ? "Smooth" : "Linear"} + + + {/* 数据统计指示器(左下角) */} + + Points: {displayData.length} | Fresh: {isDataFresh ? "✓" : "✗"} | + Compressed: {samplerStats.compressedBufferSize} + + + {/* 悬浮提示框 */} + {tooltipData.visible && ( + 200 ? "translateX(-100%)" : "translateX(0)", + boxShadow: "0 4px 12px rgba(0,0,0,0.15)", + backdropFilter: "none", + opacity: 1, + }} + > + + {tooltipData.timestamp} + + + ↑ {tooltipData.upSpeed} + + + ↓ {tooltipData.downSpeed} + + + )} + - - ); -}); + ); + }), +); EnhancedCanvasTrafficGraph.displayName = "EnhancedCanvasTrafficGraph"; diff --git a/src/components/layout/traffic-graph.tsx b/src/components/layout/traffic-graph.tsx index 82d011b51..9fd6e2a70 100644 --- a/src/components/layout/traffic-graph.tsx +++ b/src/components/layout/traffic-graph.tsx @@ -1,5 +1,5 @@ import { useTheme } from "@mui/material"; -import { useEffect, useImperativeHandle, useRef } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; const maxPoint = 30; @@ -24,7 +24,7 @@ export interface TrafficRef { /** * draw the traffic graph */ -export const TrafficGraph = ({ ref, ...props }) => { +export const TrafficGraph = forwardRef((props, ref) => { const countRef = useRef(0); const styleRef = useRef(true); const listRef = useRef(defaultList); @@ -196,4 +196,4 @@ export const TrafficGraph = ({ ref, ...props }) => { }, [palette]); return ; -}; +}); diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index 0bafb44b4..d6c4bbc7b 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -9,7 +9,13 @@ import { TextField, } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useEffect, useImperativeHandle, useRef, useState } from "react"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; import { useForm, Controller } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -32,280 +38,304 @@ export interface ProfileViewerRef { // create or edit the profile // remote / local -export const ProfileViewer = ({ - ref, - ...props -}: Props & { ref?: React.RefObject }) => { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const [openType, setOpenType] = useState<"new" | "edit">("new"); - const [loading, setLoading] = useState(false); - const { profiles } = useProfiles(); +export const ProfileViewer = forwardRef( + (props, ref) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [openType, setOpenType] = useState<"new" | "edit">("new"); + const [loading, setLoading] = useState(false); + const { profiles } = useProfiles(); - // file input - const fileDataRef = useRef(null); + // file input + const fileDataRef = useRef(null); - const { - control, - watch, - register: _register, - ...formIns - } = useForm({ - defaultValues: { - type: "remote", - name: "", - desc: "", - url: "", - option: { - with_proxy: false, - self_proxy: false, + const { + control, + watch, + register: _register, + ...formIns + } = useForm({ + defaultValues: { + type: "remote", + name: "", + desc: "", + url: "", + option: { + with_proxy: false, + self_proxy: false, + }, }, - }, - }); + }); - useImperativeHandle(ref, () => ({ - create: () => { - setOpenType("new"); - setOpen(true); - }, - edit: (item) => { - if (item) { - Object.entries(item).forEach(([key, value]) => { - formIns.setValue(key as any, value); - }); - } - setOpenType("edit"); - setOpen(true); - }, - })); + useImperativeHandle(ref, () => ({ + create: () => { + setOpenType("new"); + setOpen(true); + }, + edit: (item) => { + if (item) { + Object.entries(item).forEach(([key, value]) => { + formIns.setValue(key as any, value); + }); + } + setOpenType("edit"); + setOpen(true); + }, + })); - const selfProxy = watch("option.self_proxy"); - const withProxy = watch("option.with_proxy"); + const selfProxy = watch("option.self_proxy"); + const withProxy = watch("option.with_proxy"); - useEffect(() => { - if (selfProxy) formIns.setValue("option.with_proxy", false); - }, [selfProxy]); + useEffect(() => { + if (selfProxy) formIns.setValue("option.with_proxy", false); + }, [selfProxy]); - useEffect(() => { - if (withProxy) formIns.setValue("option.self_proxy", false); - }, [withProxy]); + useEffect(() => { + if (withProxy) formIns.setValue("option.self_proxy", false); + }, [withProxy]); - const handleOk = useLockFn( - formIns.handleSubmit(async (form) => { - if (form.option?.timeout_seconds) { - form.option.timeout_seconds = +form.option.timeout_seconds; - } - - setLoading(true); - try { - // 基本验证 - if (!form.type) throw new Error("`Type` should not be null"); - if (form.type === "remote" && !form.url) { - throw new Error("The URL should not be null"); + const handleOk = useLockFn( + formIns.handleSubmit(async (form) => { + if (form.option?.timeout_seconds) { + form.option.timeout_seconds = +form.option.timeout_seconds; } - // 处理表单数据 - if (form.option?.update_interval) { - form.option.update_interval = +form.option.update_interval; - } else { - delete form.option?.update_interval; - } - if (form.option?.user_agent === "") { - delete form.option.user_agent; - } - - const name = form.name || `${form.type} file`; - const item = { ...form, name }; - const isRemote = form.type === "remote"; - const isUpdate = openType === "edit"; - - // 判断是否是当前激活的配置 - const isActivating = isUpdate && form.uid === (profiles?.current ?? ""); - - // 保存原始代理设置以便回退成功后恢复 - const originalOptions = { - with_proxy: form.option?.with_proxy, - self_proxy: form.option?.self_proxy, - }; - - // 执行创建或更新操作,本地配置不需要回退机制 - if (!isRemote) { - if (openType === "new") { - await createProfile(item, fileDataRef.current); - } else { - if (!form.uid) throw new Error("UID not found"); - await patchProfile(form.uid, item); + setLoading(true); + try { + // 基本验证 + if (!form.type) throw new Error("`Type` should not be null"); + if (form.type === "remote" && !form.url) { + throw new Error("The URL should not be null"); } - } else { - // 远程配置使用回退机制 - try { - // 尝试正常操作 + + // 处理表单数据 + if (form.option?.update_interval) { + form.option.update_interval = +form.option.update_interval; + } else { + delete form.option?.update_interval; + } + if (form.option?.user_agent === "") { + delete form.option.user_agent; + } + + const name = form.name || `${form.type} file`; + const item = { ...form, name }; + const isRemote = form.type === "remote"; + const isUpdate = openType === "edit"; + + // 判断是否是当前激活的配置 + const isActivating = + isUpdate && form.uid === (profiles?.current ?? ""); + + // 保存原始代理设置以便回退成功后恢复 + const originalOptions = { + with_proxy: form.option?.with_proxy, + self_proxy: form.option?.self_proxy, + }; + + // 执行创建或更新操作,本地配置不需要回退机制 + if (!isRemote) { if (openType === "new") { await createProfile(item, fileDataRef.current); } else { if (!form.uid) throw new Error("UID not found"); await patchProfile(form.uid, item); } - } catch { - // 首次创建/更新失败,尝试使用自身代理 - showNotice( - "info", - t("Profile creation failed, retrying with Clash proxy..."), - ); + } else { + // 远程配置使用回退机制 + try { + // 尝试正常操作 + if (openType === "new") { + await createProfile(item, fileDataRef.current); + } else { + if (!form.uid) throw new Error("UID not found"); + await patchProfile(form.uid, item); + } + } catch { + // 首次创建/更新失败,尝试使用自身代理 + showNotice( + "info", + t("Profile creation failed, retrying with Clash proxy..."), + ); - // 使用自身代理的配置 - const retryItem = { - ...item, - option: { - ...item.option, - with_proxy: false, - self_proxy: true, - }, - }; + // 使用自身代理的配置 + const retryItem = { + ...item, + option: { + ...item.option, + with_proxy: false, + self_proxy: true, + }, + }; - // 使用自身代理再次尝试 - if (openType === "new") { - await createProfile(retryItem, fileDataRef.current); - } else { - if (!form.uid) throw new Error("UID not found"); - await patchProfile(form.uid, retryItem); + // 使用自身代理再次尝试 + if (openType === "new") { + await createProfile(retryItem, fileDataRef.current); + } else { + if (!form.uid) throw new Error("UID not found"); + await patchProfile(form.uid, retryItem); - // 编辑模式下恢复原始代理设置 - await patchProfile(form.uid, { option: originalOptions }); + // 编辑模式下恢复原始代理设置 + await patchProfile(form.uid, { option: originalOptions }); + } + + showNotice( + "success", + t("Profile creation succeeded with Clash proxy"), + ); } - - showNotice( - "success", - t("Profile creation succeeded with Clash proxy"), - ); } + + // 成功后的操作 + setOpen(false); + setTimeout(() => formIns.reset(), 500); + fileDataRef.current = null; + + // 优化:UI先关闭,异步通知父组件 + setTimeout(() => { + props.onChange(isActivating); + }, 0); + } catch (err: any) { + showNotice("error", err.message || err.toString()); + } finally { + setLoading(false); } + }), + ); - // 成功后的操作 + const handleClose = () => { + try { setOpen(false); - setTimeout(() => formIns.reset(), 500); fileDataRef.current = null; - - // 优化:UI先关闭,异步通知父组件 - setTimeout(() => { - props.onChange(isActivating); - }, 0); - } catch (err: any) { - showNotice("error", err.message || err.toString()); - } finally { - setLoading(false); + setTimeout(() => formIns.reset(), 500); + } catch (e) { + console.warn("[ProfileViewer] handleClose error:", e); } - }), - ); + }; - const handleClose = () => { - try { - setOpen(false); - fileDataRef.current = null; - setTimeout(() => formIns.reset(), 500); - } catch (e) { - console.warn("[ProfileViewer] handleClose error:", e); - } - }; + const text = { + fullWidth: true, + size: "small", + margin: "normal", + variant: "outlined", + autoComplete: "off", + autoCorrect: "off", + } as const; - const text = { - fullWidth: true, - size: "small", - margin: "normal", - variant: "outlined", - autoComplete: "off", - autoCorrect: "off", - } as const; + const formType = watch("type"); + const isRemote = formType === "remote"; + const isLocal = formType === "local"; - const formType = watch("type"); - const isRemote = formType === "remote"; - const isLocal = formType === "local"; + return ( + + ( + + {t("Type")} + + + )} + /> - return ( - - ( - - {t("Type")} - - + ( + + )} + /> + + ( + + )} + /> + + {isRemote && ( + <> + ( + + )} + /> + + ( + + )} + /> + + ( + + {t("seconds")} + + ), + }, + }} + /> + )} + /> + )} - /> - ( - - )} - /> - - ( - - )} - /> - - {isRemote && ( - <> + {(isRemote || isLocal) && ( ( - - )} - /> - - ( - - )} - /> - - ( - {t("seconds")} + {t("mins")} ), }, @@ -313,79 +343,57 @@ export const ProfileViewer = ({ /> )} /> - - )} + )} - {(isRemote || isLocal) && ( - ( - {t("mins")} - ), - }, - }} + {isLocal && openType === "new" && ( + { + formIns.setValue("name", formIns.getValues("name") || file.name); + fileDataRef.current = val; + }} + /> + )} + + {isRemote && ( + <> + ( + + {t("Use System Proxy")} + + + )} /> - )} - /> - )} - {isLocal && openType === "new" && ( - { - formIns.setValue("name", formIns.getValues("name") || file.name); - fileDataRef.current = val; - }} - /> - )} + ( + + {t("Use Clash Proxy")} + + + )} + /> - {isRemote && ( - <> - ( - - {t("Use System Proxy")} - - - )} - /> - - ( - - {t("Use Clash Proxy")} - - - )} - /> - - ( - - {t("Accept Invalid Certs (Danger)")} - - - )} - /> - - )} - - ); -}; + ( + + {t("Accept Invalid Certs (Danger)")} + + + )} + /> + + )} + + ); + }, +); const StyledBox = styled(Box)(() => ({ margin: "8px 0 8px 8px", diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 33e6b5535..476f5f907 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -1,19 +1,23 @@ -import { ExpandMoreRounded } from "@mui/icons-material"; import { Box, Snackbar, Alert, Chip, + Stack, Typography, IconButton, + Collapse, Menu, MenuItem, + Divider, + Button, } from "@mui/material"; +import { ArchiveOutlined, ExpandMoreRounded } from "@mui/icons-material"; import { useLockFn } from "ahooks"; import { useRef, useState, useEffect, useCallback, useMemo } from "react"; +import useSWR from "swr"; import { useTranslation } from "react-i18next"; import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; -import useSWR from "swr"; import { useProxySelection } from "@/hooks/use-proxy-selection"; import { useVerge } from "@/hooks/use-verge"; @@ -30,8 +34,8 @@ import { BaseEmpty } from "../base"; import { ScrollTopButton } from "../layout/scroll-top-button"; import { ProxyChain } from "./proxy-chain"; -import { ProxyGroupNavigator } from "./proxy-group-navigator"; import { ProxyRender } from "./proxy-render"; +import { ProxyGroupNavigator } from "./proxy-group-navigator"; import { useRenderList } from "./use-render-list"; interface Props { diff --git a/src/components/setting/mods/backup-viewer.tsx b/src/components/setting/mods/backup-viewer.tsx index 8c03e4c74..804f35410 100644 --- a/src/components/setting/mods/backup-viewer.tsx +++ b/src/components/setting/mods/backup-viewer.tsx @@ -1,10 +1,16 @@ import { Box, Paper, Divider } from "@mui/material"; import dayjs from "dayjs"; import customParseFormat from "dayjs/plugin/customParseFormat"; -import { useImperativeHandle, useState, useCallback, useMemo } from "react"; +import { + forwardRef, + useImperativeHandle, + useState, + useCallback, + useMemo, +} from "react"; import { useTranslation } from "react-i18next"; -import { BaseDialog } from "@/components/base"; +import { BaseDialog, DialogRef } from "@/components/base"; import { BaseLoadingOverlay } from "@/components/base"; import { listWebDavBackup } from "@/services/cmds"; @@ -19,7 +25,7 @@ dayjs.extend(customParseFormat); const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss"; const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/; -export const BackupViewer = ({ ref, ...props }) => { +export const BackupViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -125,4 +131,4 @@ export const BackupViewer = ({ ref, ...props }) => { ); -}; +}); diff --git a/src/components/setting/mods/clash-core-viewer.tsx b/src/components/setting/mods/clash-core-viewer.tsx index f41626def..869b57842 100644 --- a/src/components/setting/mods/clash-core-viewer.tsx +++ b/src/components/setting/mods/clash-core-viewer.tsx @@ -12,11 +12,11 @@ import { ListItemText, } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; import { mutate } from "swr"; -import { BaseDialog } from "@/components/base"; +import { BaseDialog, DialogRef } from "@/components/base"; import { useVerge } from "@/hooks/use-verge"; import { changeClashCore, restartCore } from "@/services/cmds"; import { @@ -31,7 +31,7 @@ const VALID_CORE = [ { name: "Mihomo Alpha", core: "verge-mihomo-alpha", chip: "Alpha Version" }, ]; -export const ClashCoreViewer = ({ ref, ...props }) => { +export const ClashCoreViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const { verge, mutateVerge } = useVerge(); @@ -169,4 +169,4 @@ export const ClashCoreViewer = ({ ref, ...props }) => { ); -}; +}); diff --git a/src/components/setting/mods/clash-port-viewer.tsx b/src/components/setting/mods/clash-port-viewer.tsx index e0db3184e..ec4958040 100644 --- a/src/components/setting/mods/clash-port-viewer.tsx +++ b/src/components/setting/mods/clash-port-viewer.tsx @@ -9,7 +9,7 @@ import { TextField, } from "@mui/material"; import { useLockFn, useRequest } from "ahooks"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; import { BaseDialog, Switch } from "@/components/base"; @@ -30,12 +30,10 @@ interface ClashPortViewerRef { const generateRandomPort = () => Math.floor(Math.random() * (65535 - 1025 + 1)) + 1025; -export const ClashPortViewer = ({ - ref, - ...props -}: ClashPortViewerProps & { - ref?: React.RefObject; -}) => { +export const ClashPortViewer = forwardRef< + ClashPortViewerRef, + ClashPortViewerProps +>((props, ref) => { const { t } = useTranslation(); const { clashInfo, patchInfo } = useClashInfo(); const { verge, patchVerge } = useVerge(); @@ -350,4 +348,4 @@ export const ClashPortViewer = ({ ); -}; +}); diff --git a/src/components/setting/mods/config-viewer.tsx b/src/components/setting/mods/config-viewer.tsx index e68030f96..26609caa0 100644 --- a/src/components/setting/mods/config-viewer.tsx +++ b/src/components/setting/mods/config-viewer.tsx @@ -1,11 +1,12 @@ import { Box, Chip } from "@mui/material"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; +import { DialogRef } from "@/components/base"; import { EditorViewer } from "@/components/profile/editor-viewer"; import { getRuntimeYaml } from "@/services/cmds"; -export const ConfigViewer = ({ ref, ..._ }) => { +export const ConfigViewer = forwardRef((_, ref) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [runtimeConfig, setRuntimeConfig] = useState(""); @@ -37,4 +38,4 @@ export const ConfigViewer = ({ ref, ..._ }) => { onClose={() => setOpen(false)} /> ); -}; +}); diff --git a/src/components/setting/mods/controller-viewer.tsx b/src/components/setting/mods/controller-viewer.tsx index 154ff5231..f39b928b6 100644 --- a/src/components/setting/mods/controller-viewer.tsx +++ b/src/components/setting/mods/controller-viewer.tsx @@ -12,15 +12,15 @@ import { Tooltip, } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BaseDialog, Switch } from "@/components/base"; +import { BaseDialog, DialogRef, Switch } from "@/components/base"; import { useClashInfo } from "@/hooks/use-clash"; import { useVerge } from "@/hooks/use-verge"; import { showNotice } from "@/services/noticeService"; -export const ControllerViewer = ({ ref, ...props }) => { +export const ControllerViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [copySuccess, setCopySuccess] = useState(null); @@ -217,4 +217,4 @@ export const ControllerViewer = ({ ref, ...props }) => { ); -}; +}); diff --git a/src/components/setting/mods/dns-viewer.tsx b/src/components/setting/mods/dns-viewer.tsx index 6f3cf0715..f076dbc06 100644 --- a/src/components/setting/mods/dns-viewer.tsx +++ b/src/components/setting/mods/dns-viewer.tsx @@ -15,11 +15,11 @@ import { import { invoke } from "@tauri-apps/api/core"; import { useLockFn } from "ahooks"; import yaml from "js-yaml"; -import { useImperativeHandle, useState, useEffect } from "react"; +import { forwardRef, useImperativeHandle, useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import MonacoEditor from "react-monaco-editor"; -import { BaseDialog, Switch } from "@/components/base"; +import { BaseDialog, DialogRef, Switch } from "@/components/base"; import { useClash } from "@/hooks/use-clash"; import { showNotice } from "@/services/noticeService"; import { useThemeMode } from "@/services/states"; @@ -87,7 +87,7 @@ const DEFAULT_DNS_CONFIG = { }, }; -export const DnsViewer = ({ ref, ...props }) => { +export const DnsViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const { clash, mutateClash } = useClash(); const themeMode = useThemeMode(); @@ -1034,4 +1034,4 @@ export const DnsViewer = ({ ref, ...props }) => { )} ); -}; +}); diff --git a/src/components/setting/mods/external-controller-cors.tsx b/src/components/setting/mods/external-controller-cors.tsx index 355e316e4..d2522f359 100644 --- a/src/components/setting/mods/external-controller-cors.tsx +++ b/src/components/setting/mods/external-controller-cors.tsx @@ -1,7 +1,7 @@ import { Delete as DeleteIcon } from "@mui/icons-material"; import { Box, Button, Divider, List, ListItem, TextField } from "@mui/material"; import { useLockFn, useRequest } from "ahooks"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; import { BaseDialog, Switch } from "@/components/base"; @@ -71,194 +71,201 @@ interface ClashHeaderConfigingRef { close: () => void; } -export const HeaderConfiguration = ({ ref, ...props }) => { - const { t } = useTranslation(); - const { clash, mutateClash, patchClash } = useClash(); - const [open, setOpen] = useState(false); +export const HeaderConfiguration = forwardRef( + (props, ref) => { + const { t } = useTranslation(); + const { clash, mutateClash, patchClash } = useClash(); + const [open, setOpen] = useState(false); - // CORS配置状态管理 - const [corsConfig, setCorsConfig] = useState<{ - allowPrivateNetwork: boolean; - allowOrigins: string[]; - }>(() => { - const cors = clash?.["external-controller-cors"]; - const origins = cors?.["allow-origins"] ?? []; - return { - allowPrivateNetwork: cors?.["allow-private-network"] ?? true, - allowOrigins: filterBaseOriginsForUI(origins), - }; - }); - - // 处理CORS配置变更 - const handleCorsConfigChange = ( - key: "allowPrivateNetwork" | "allowOrigins", - value: boolean | string[], - ) => { - setCorsConfig((prev) => ({ - ...prev, - [key]: value, - })); - }; - - // 添加新的允许来源 - const handleAddOrigin = () => { - handleCorsConfigChange("allowOrigins", [...corsConfig.allowOrigins, ""]); - }; - - // 更新允许来源列表中的某一项 - const handleUpdateOrigin = (index: number, value: string) => { - const newOrigins = [...corsConfig.allowOrigins]; - newOrigins[index] = value; - handleCorsConfigChange("allowOrigins", newOrigins); - }; - - // 删除允许来源列表中的某一项 - const handleDeleteOrigin = (index: number) => { - const newOrigins = [...corsConfig.allowOrigins]; - newOrigins.splice(index, 1); - handleCorsConfigChange("allowOrigins", newOrigins); - }; - - // 保存配置请求 - const { loading, run: saveConfig } = useRequest( - async () => { - // 保存时使用完整的源列表(包括开发URL) - const fullOrigins = getFullOrigins(corsConfig.allowOrigins); - - await patchClash({ - "external-controller-cors": { - "allow-private-network": corsConfig.allowPrivateNetwork, - "allow-origins": fullOrigins.filter( - (origin: string) => origin.trim() !== "", - ), - }, - }); - await mutateClash(); - }, - { - manual: true, - onSuccess: () => { - setOpen(false); - showNotice("success", t("Configuration saved successfully")); - }, - onError: () => { - showNotice("error", t("Failed to save configuration")); - }, - }, - ); - - useImperativeHandle(ref, () => ({ - open: () => { + // CORS配置状态管理 + const [corsConfig, setCorsConfig] = useState<{ + allowPrivateNetwork: boolean; + allowOrigins: string[]; + }>(() => { const cors = clash?.["external-controller-cors"]; const origins = cors?.["allow-origins"] ?? []; - setCorsConfig({ + return { allowPrivateNetwork: cors?.["allow-private-network"] ?? true, allowOrigins: filterBaseOriginsForUI(origins), - }); - setOpen(true); - }, - close: () => setOpen(false), - })); + }; + }); - const handleSave = useLockFn(async () => { - await saveConfig(); - }); + // 处理CORS配置变更 + const handleCorsConfigChange = ( + key: "allowPrivateNetwork" | "allowOrigins", + value: boolean | string[], + ) => { + setCorsConfig((prev) => ({ + ...prev, + [key]: value, + })); + }; - return ( - setOpen(false)} - onCancel={() => setOpen(false)} - onOk={handleSave} - > - - - - - {t("Allow private network access")} - - - handleCorsConfigChange("allowPrivateNetwork", e.target.checked) - } - /> - - + // 添加新的允许来源 + const handleAddOrigin = () => { + handleCorsConfigChange("allowOrigins", [...corsConfig.allowOrigins, ""]); + }; - + // 更新允许来源列表中的某一项 + const handleUpdateOrigin = (index: number, value: string) => { + const newOrigins = [...corsConfig.allowOrigins]; + newOrigins[index] = value; + handleCorsConfigChange("allowOrigins", newOrigins); + }; + + // 删除允许来源列表中的某一项 + const handleDeleteOrigin = (index: number) => { + const newOrigins = [...corsConfig.allowOrigins]; + newOrigins.splice(index, 1); + handleCorsConfigChange("allowOrigins", newOrigins); + }; + + // 保存配置请求 + const { loading, run: saveConfig } = useRequest( + async () => { + // 保存时使用完整的源列表(包括开发URL) + const fullOrigins = getFullOrigins(corsConfig.allowOrigins); + + await patchClash({ + "external-controller-cors": { + "allow-private-network": corsConfig.allowPrivateNetwork, + "allow-origins": fullOrigins.filter( + (origin: string) => origin.trim() !== "", + ), + }, + }); + await mutateClash(); + }, + { + manual: true, + onSuccess: () => { + setOpen(false); + showNotice("success", t("Configuration saved successfully")); + }, + onError: () => { + showNotice("error", t("Failed to save configuration")); + }, + }, + ); + + useImperativeHandle(ref, () => ({ + open: () => { + const cors = clash?.["external-controller-cors"]; + const origins = cors?.["allow-origins"] ?? []; + setCorsConfig({ + allowPrivateNetwork: cors?.["allow-private-network"] ?? true, + allowOrigins: filterBaseOriginsForUI(origins), + }); + setOpen(true); + }, + close: () => setOpen(false), + })); + + const handleSave = useLockFn(async () => { + await saveConfig(); + }); + + return ( + setOpen(false)} + onCancel={() => setOpen(false)} + onOk={handleSave} + > + + + + + {t("Allow private network access")} + + + handleCorsConfigChange( + "allowPrivateNetwork", + e.target.checked, + ) + } + /> + + + + + + +
+
+ {t("Allowed Origins")} +
+ {corsConfig.allowOrigins.map((origin, index) => ( +
+ handleUpdateOrigin(index, e.target.value)} + placeholder={t("Please enter a valid url")} + inputProps={{ style: { fontSize: 14 } }} + /> + +
+ ))} + - -
-
- {t("Allowed Origins")} -
- {corsConfig.allowOrigins.map((origin, index) => (
- handleUpdateOrigin(index, e.target.value)} - placeholder={t("Please enter a valid url")} - inputProps={{ style: { fontSize: 14 } }} - /> - -
- ))} - - -
-
- {t("Always included origins: {{urls}}", { - urls: DEV_URLS.join(", "), - })} + {t("Always included origins: {{urls}}", { + urls: DEV_URLS.join(", "), + })} +
-
-
-
-
- ); -}; + +
+
+ ); + }, +); diff --git a/src/components/setting/mods/hotkey-viewer.tsx b/src/components/setting/mods/hotkey-viewer.tsx index 8ea690fa3..cd09e244c 100644 --- a/src/components/setting/mods/hotkey-viewer.tsx +++ b/src/components/setting/mods/hotkey-viewer.tsx @@ -1,9 +1,9 @@ import { styled, Typography } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BaseDialog, Switch } from "@/components/base"; +import { BaseDialog, DialogRef, Switch } from "@/components/base"; import { useVerge } from "@/hooks/use-verge"; import { showNotice } from "@/services/noticeService"; @@ -26,7 +26,7 @@ const HOTKEY_FUNC = [ "entry_lightweight_mode", ]; -export const HotkeyViewer = ({ ref, ...props }) => { +export const HotkeyViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -117,4 +117,4 @@ export const HotkeyViewer = ({ ref, ...props }) => { ))} ); -}; +}); diff --git a/src/components/setting/mods/layout-viewer.tsx b/src/components/setting/mods/layout-viewer.tsx index 809515528..5e0f1e253 100644 --- a/src/components/setting/mods/layout-viewer.tsx +++ b/src/components/setting/mods/layout-viewer.tsx @@ -12,10 +12,10 @@ import { convertFileSrc } from "@tauri-apps/api/core"; import { join } from "@tauri-apps/api/path"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { exists } from "@tauri-apps/plugin-fs"; -import { useEffect, useImperativeHandle, useState } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BaseDialog, Switch } from "@/components/base"; +import { BaseDialog, DialogRef, Switch } from "@/components/base"; import { TooltipIcon } from "@/components/base/base-tooltip-icon"; import { useVerge } from "@/hooks/use-verge"; import { copyIconFile, getAppDir } from "@/services/cmds"; @@ -38,7 +38,7 @@ const getIcons = async (icon_dir: string, name: string) => { }; }; -export const LayoutViewer = ({ ref, ...props }) => { +export const LayoutViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const { verge, patchVerge, mutateVerge } = useVerge(); @@ -387,7 +387,7 @@ export const LayoutViewer = ({ ref, ...props }) => { ); -}; +}); const Item = styled(ListItem)(() => ({ padding: "5px 2px", diff --git a/src/components/setting/mods/lite-mode-viewer.tsx b/src/components/setting/mods/lite-mode-viewer.tsx index 30f534a2d..6c2436675 100644 --- a/src/components/setting/mods/lite-mode-viewer.tsx +++ b/src/components/setting/mods/lite-mode-viewer.tsx @@ -7,16 +7,16 @@ import { InputAdornment, } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BaseDialog, Switch } from "@/components/base"; +import { BaseDialog, DialogRef, Switch } from "@/components/base"; import { TooltipIcon } from "@/components/base/base-tooltip-icon"; import { useVerge } from "@/hooks/use-verge"; import { entry_lightweight_mode } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; -export const LiteModeViewer = ({ ref, ...props }) => { +export const LiteModeViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const { verge, patchVerge } = useVerge(); @@ -143,4 +143,4 @@ export const LiteModeViewer = ({ ref, ...props }) => { ); -}; +}); diff --git a/src/components/setting/mods/misc-viewer.tsx b/src/components/setting/mods/misc-viewer.tsx index cee028821..b0048fd81 100644 --- a/src/components/setting/mods/misc-viewer.tsx +++ b/src/components/setting/mods/misc-viewer.tsx @@ -8,15 +8,15 @@ import { TextField, } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BaseDialog, Switch } from "@/components/base"; +import { BaseDialog, DialogRef, Switch } from "@/components/base"; import { TooltipIcon } from "@/components/base/base-tooltip-icon"; import { useVerge } from "@/hooks/use-verge"; import { showNotice } from "@/services/noticeService"; -export const MiscViewer = ({ ref, ...props }) => { +export const MiscViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const { verge, patchVerge } = useVerge(); @@ -319,4 +319,4 @@ export const MiscViewer = ({ ref, ...props }) => { ); -}; +}); diff --git a/src/components/setting/mods/network-interface-viewer.tsx b/src/components/setting/mods/network-interface-viewer.tsx index 5b7d04229..912529eb7 100644 --- a/src/components/setting/mods/network-interface-viewer.tsx +++ b/src/components/setting/mods/network-interface-viewer.tsx @@ -1,15 +1,15 @@ import { ContentCopyRounded } from "@mui/icons-material"; import { alpha, Box, Button, IconButton } from "@mui/material"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; -import { BaseDialog } from "@/components/base"; +import { BaseDialog, DialogRef } from "@/components/base"; import { getNetworkInterfacesInfo } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; -export const NetworkInterfaceViewer = ({ ref, ...props }) => { +export const NetworkInterfaceViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [isV4, setIsV4] = useState(true); @@ -99,7 +99,7 @@ export const NetworkInterfaceViewer = ({ ref, ...props }) => { ))} ); -}; +}); const AddressDisplay = (props: { label: string; content: string }) => { const { t } = useTranslation(); diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 9649a3f6f..329d857ed 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -11,19 +11,25 @@ import { Typography, } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useEffect, useImperativeHandle, useMemo, useState } from "react"; +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import useSWR, { mutate } from "swr"; -import { BaseDialog, Switch } from "@/components/base"; +import { BaseDialog, DialogRef, Switch } from "@/components/base"; import { BaseFieldset } from "@/components/base/base-fieldset"; import { TooltipIcon } from "@/components/base/base-tooltip-icon"; import { EditorViewer } from "@/components/profile/editor-viewer"; import { useVerge } from "@/hooks/use-verge"; import { useAppData } from "@/providers/app-data-provider"; +import { getClashConfig } from "@/services/cmds"; import { getAutotemProxy, - getClashConfig, getNetworkInterfacesInfo, getSystemHostname, getSystemProxy, @@ -69,7 +75,7 @@ const getValidReg = (isWindows: boolean) => { return new RegExp(rValid); }; -export const SysproxyViewer = ({ ref, ...props }) => { +export const SysproxyViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const isWindows = getSystem() === "windows"; const validReg = useMemo(() => getValidReg(isWindows), [isWindows]); @@ -613,7 +619,7 @@ export const SysproxyViewer = ({ ref, ...props }) => { ); -}; +}); const FlexBox = styled("div")` display: flex; diff --git a/src/components/setting/mods/theme-viewer.tsx b/src/components/setting/mods/theme-viewer.tsx index f59fae53f..e3f764c5e 100644 --- a/src/components/setting/mods/theme-viewer.tsx +++ b/src/components/setting/mods/theme-viewer.tsx @@ -9,16 +9,16 @@ import { useTheme, } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BaseDialog } from "@/components/base"; +import { BaseDialog, DialogRef } from "@/components/base"; import { EditorViewer } from "@/components/profile/editor-viewer"; import { useVerge } from "@/hooks/use-verge"; import { defaultTheme, defaultDarkTheme } from "@/pages/_theme"; import { showNotice } from "@/services/noticeService"; -export const ThemeViewer = ({ ref, ...props }) => { +export const ThemeViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -144,7 +144,7 @@ export const ThemeViewer = ({ ref, ...props }) => { ); -}; +}); const Item = styled(ListItem)(() => ({ padding: "5px 2px", diff --git a/src/components/setting/mods/tun-viewer.tsx b/src/components/setting/mods/tun-viewer.tsx index d3667573d..cd361cc96 100644 --- a/src/components/setting/mods/tun-viewer.tsx +++ b/src/components/setting/mods/tun-viewer.tsx @@ -8,10 +8,10 @@ import { TextField, } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BaseDialog, Switch } from "@/components/base"; +import { BaseDialog, DialogRef, Switch } from "@/components/base"; import { useClash } from "@/hooks/use-clash"; import { enhanceProfiles } from "@/services/cmds"; import { showNotice } from "@/services/noticeService"; @@ -21,7 +21,7 @@ import { StackModeSwitch } from "./stack-mode-switch"; const OS = getSystem(); -export const TunViewer = ({ ref, ...props }) => { +export const TunViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const { clash, mutateClash, patchClash } = useClash(); @@ -238,4 +238,4 @@ export const TunViewer = ({ ref, ...props }) => { ); -}; +}); diff --git a/src/components/setting/mods/update-viewer.tsx b/src/components/setting/mods/update-viewer.tsx index a2fa367e9..3b67e1751 100644 --- a/src/components/setting/mods/update-viewer.tsx +++ b/src/components/setting/mods/update-viewer.tsx @@ -4,18 +4,24 @@ import { relaunch } from "@tauri-apps/plugin-process"; import { open as openUrl } from "@tauri-apps/plugin-shell"; import { check as checkUpdate } from "@tauri-apps/plugin-updater"; import { useLockFn } from "ahooks"; -import { useImperativeHandle, useState, useMemo, useEffect } from "react"; +import { + forwardRef, + useImperativeHandle, + useState, + useMemo, + useEffect, +} from "react"; import { useTranslation } from "react-i18next"; import ReactMarkdown from "react-markdown"; import useSWR from "swr"; -import { BaseDialog } from "@/components/base"; +import { BaseDialog, DialogRef } from "@/components/base"; import { useListen } from "@/hooks/use-listen"; import { portableFlag } from "@/pages/_layout"; import { showNotice } from "@/services/noticeService"; import { useUpdateState, useSetUpdateState } from "@/services/states"; -export const UpdateViewer = ({ ref, ...props }) => { +export const UpdateViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -161,4 +167,4 @@ export const UpdateViewer = ({ ref, ...props }) => { )} ); -}; +}); diff --git a/src/components/setting/mods/web-ui-viewer.tsx b/src/components/setting/mods/web-ui-viewer.tsx index 655be1e9a..e82035fcd 100644 --- a/src/components/setting/mods/web-ui-viewer.tsx +++ b/src/components/setting/mods/web-ui-viewer.tsx @@ -1,9 +1,9 @@ import { Button, Box, Typography } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BaseDialog, BaseEmpty } from "@/components/base"; +import { BaseDialog, BaseEmpty, DialogRef } from "@/components/base"; import { useClashInfo } from "@/hooks/use-clash"; import { useVerge } from "@/hooks/use-verge"; import { openWebUrl } from "@/services/cmds"; @@ -11,7 +11,7 @@ import { showNotice } from "@/services/noticeService"; import { WebUIItem } from "./web-ui-item"; -export const WebUIViewer = ({ ref, ...props }) => { +export const WebUIViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const { clashInfo } = useClashInfo(); @@ -139,4 +139,4 @@ export const WebUIViewer = ({ ref, ...props }) => { )} ); -}; +}); diff --git a/src/components/test/test-viewer.tsx b/src/components/test/test-viewer.tsx index 56453bb93..0962c4249 100644 --- a/src/components/test/test-viewer.tsx +++ b/src/components/test/test-viewer.tsx @@ -1,7 +1,7 @@ import { TextField } from "@mui/material"; import { useLockFn } from "ahooks"; import { nanoid } from "nanoid"; -import { useImperativeHandle, useState } from "react"; +import { forwardRef, useImperativeHandle, useState } from "react"; import { useForm, Controller } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -19,10 +19,7 @@ export interface TestViewerRef { } // create or edit the test item -export const TestViewer = ({ - ref, - ...props -}: Props & { ref?: React.RefObject }) => { +export const TestViewer = forwardRef((props, ref) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [openType, setOpenType] = useState<"new" | "edit">("new"); @@ -176,4 +173,4 @@ export const TestViewer = ({ /> ); -}; +}); diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index 8abd13803..16ad39625 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -1,5 +1,11 @@ import { listen } from "@tauri-apps/api/event"; -import React, { createContext, use, useEffect, useMemo, useRef } from "react"; +import React, { + createContext, + useContext, + useEffect, + useMemo, + useRef, +} from "react"; import useSWR from "swr"; import { useClashInfo } from "@/hooks/use-clash"; @@ -583,12 +589,14 @@ export const AppDataProvider = ({ refreshAll, ]); - return {children}; + return ( + {children} + ); }; // 自定义Hook访问全局数据 export const useAppData = () => { - const context = use(AppDataContext); + const context = useContext(AppDataContext); if (!context) { throw new Error("useAppData必须在AppDataProvider内使用"); diff --git a/src/providers/chain-proxy-provider.tsx b/src/providers/chain-proxy-provider.tsx index c8ccce1d5..2b834f89c 100644 --- a/src/providers/chain-proxy-provider.tsx +++ b/src/providers/chain-proxy-provider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, use, useState } from "react"; +import React, { createContext, useCallback, useContext, useState } from "react"; interface ChainProxyContextType { isChainMode: boolean; @@ -26,7 +26,7 @@ export const ChainProxyProvider = ({ }, []); return ( - {children} - + ); }; export const useChainProxy = () => { - const context = use(ChainProxyContext); + const context = useContext(ChainProxyContext); if (!context) { throw new Error("useChainProxy must be used within a ChainProxyProvider"); }