From f2073a2f837dd996822e1b059d60b5cad3422b26 Mon Sep 17 00:00:00 2001 From: "Junkai W." <129588175+Be-Forever223@users.noreply.github.com> Date: Mon, 15 Sep 2025 07:44:54 +0800 Subject: [PATCH] =?UTF-8?q?Add=20Func=20=E9=93=BE=E5=BC=8F=E4=BB=A3?= =?UTF-8?q?=E7=90=86=20(#4624)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 添加链式代理gui和语言支持 在Iruntime中添跟新链式代理配置方法 同时添加了cmd * 修复读取运行时代理链配置文件bug * t * 完成链式代理配置构造 * 修复获取链式代理运行时配置的bug * 完整的链式代理功能 --- src-tauri/src/cmd/proxy.rs | 2 + src-tauri/src/cmd/runtime.rs | 90 +++- src-tauri/src/config/runtime.rs | 135 ++++++ src-tauri/src/ipc/general.rs | 1 - src-tauri/src/lib.rs | 2 + src/components/proxy/proxy-chain.tsx | 586 ++++++++++++++++++++++++ src/components/proxy/proxy-groups.tsx | 423 +++++------------ src/components/proxy/proxy-render.tsx | 12 +- src/components/proxy/use-render-list.ts | 123 ++++- src/locales/ar.json | 1 + src/locales/de.json | 1 + src/locales/en.json | 15 +- src/locales/es.json | 1 + src/locales/fa.json | 1 + src/locales/id.json | 1 + src/locales/jp.json | 1 + src/locales/ko.json | 2 +- src/locales/ru.json | 7 + src/locales/tr.json | 1 + src/locales/tt.json | 1 + src/locales/zh.json | 15 +- src/locales/zhtw.json | 6 + src/pages/proxies.tsx | 77 +++- src/providers/chain-proxy-provider.tsx | 48 ++ src/services/cmds.ts | 10 + 25 files changed, 1246 insertions(+), 316 deletions(-) create mode 100644 src/components/proxy/proxy-chain.tsx create mode 100644 src/providers/chain-proxy-provider.tsx diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index 1b1aba55..f2f9a2a9 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -76,6 +76,7 @@ pub async fn sync_tray_proxy_selection() -> CmdResult<()> { pub async fn update_proxy_and_sync(group: String, proxy: String) -> CmdResult<()> { match IpcManager::global().update_proxy(&group, &proxy).await { Ok(_) => { + // println!("Proxy updated successfully: {} -> {}", group,proxy); logging!( info, Type::Cmd, @@ -107,6 +108,7 @@ pub async fn update_proxy_and_sync(group: String, proxy: String) -> CmdResult<() Ok(()) } Err(e) => { + println!("1111111111111111"); logging!( error, Type::Cmd, diff --git a/src-tauri/src/cmd/runtime.rs b/src-tauri/src/cmd/runtime.rs index ff78cb09..2b7e3e24 100644 --- a/src-tauri/src/cmd/runtime.rs +++ b/src-tauri/src/cmd/runtime.rs @@ -1,5 +1,5 @@ use super::CmdResult; -use crate::{config::*, wrap_err}; +use crate::{config::*, core::CoreManager, log_err, wrap_err}; use anyhow::Context; use serde_yaml_ng::Mapping; use std::collections::HashMap; @@ -15,6 +15,7 @@ pub async fn get_runtime_config() -> CmdResult> { pub async fn get_runtime_yaml() -> CmdResult { let runtime = Config::runtime().await; let runtime = runtime.latest_ref(); + let config = runtime.config.as_ref(); wrap_err!( config @@ -35,3 +36,90 @@ pub async fn get_runtime_exists() -> CmdResult> { pub async fn get_runtime_logs() -> CmdResult>> { Ok(Config::runtime().await.latest_ref().chain_logs.clone()) } + +/// 读取运行时链式代理配置 +#[tauri::command] +pub async fn get_runtime_proxy_chain_config() -> CmdResult { + let runtime = Config::runtime().await; + let runtime = runtime.latest_ref(); + + let config = wrap_err!( + runtime + .config + .as_ref() + .ok_or(anyhow::anyhow!("failed to parse config to yaml file")) + )?; + + if let ( + Some(serde_yaml_ng::Value::Sequence(proxies)), + Some(serde_yaml_ng::Value::Sequence(proxy_groups)), + ) = (config.get("proxies"), config.get("proxy-groups")) + { + let mut proxy_name = None; + let mut proxies_chain = Vec::new(); + + let proxy_chain_groups = proxy_groups + .iter() + .filter_map( + |proxy_group| match proxy_group.get("name").and_then(|n| n.as_str()) { + Some("proxy_chain") => { + if let Some(serde_yaml_ng::Value::Sequence(ps)) = proxy_group.get("proxies") + && let Some(x) = ps.first() + { + proxy_name = Some(x); //插入出口节点名字 + } + Some(proxy_group.to_owned()) + } + _ => None, + }, + ) + .collect::>(); + + while let Some(proxy) = proxies.iter().find(|proxy| { + if let serde_yaml_ng::Value::Mapping(proxy_map) = proxy { + proxy_map.get("name") == proxy_name && proxy_map.get("dialer-proxy").is_some() + } else { + false + } + }) { + proxies_chain.push(proxy.to_owned()); + proxy_name = proxy.get("dialer-proxy"); + } + + if let Some(entry_proxy) = proxies.iter().find(|proxy| proxy.get("name") == proxy_name) { + proxies_chain.push(entry_proxy.to_owned()); + } + + proxies_chain.reverse(); + + let mut config: HashMap> = HashMap::new(); + config.insert("proxies".to_string(), proxies_chain); + config.insert("proxy-groups".to_string(), proxy_chain_groups); + + wrap_err!(serde_yaml_ng::to_string(&config).context("YAML generation failed")) + } else { + wrap_err!(Err(anyhow::anyhow!( + "failed to get proxies or proxy-groups".to_string() + ))) + } +} + +/// 更新运行时链式代理配置 +#[tauri::command] +pub async fn update_proxy_chain_config_in_runtime( + proxy_chain_config: Option, +) -> CmdResult<()> { + { + let runtime = Config::runtime().await; + let mut draft = runtime.draft_mut(); + draft.update_proxy_chain_config(proxy_chain_config); + drop(draft); + runtime.apply(); + } + + // 生成新的运行配置文件并通知 Clash 核心重新加载 + let run_path = wrap_err!(Config::generate_file(ConfigType::Run).await)?; + log_err!(CoreManager::global().put_configs_force(run_path).await); + + Ok(()) +} diff --git a/src-tauri/src/config/runtime.rs b/src-tauri/src/config/runtime.rs index 03278cc6..3c4cd6f8 100644 --- a/src-tauri/src/config/runtime.rs +++ b/src-tauri/src/config/runtime.rs @@ -46,4 +46,139 @@ impl IRuntime { } } } + + //跟新链式代理配置文件 + /// { + /// "proxies":[ + /// { + /// name : 入口节点, + /// type: xxx + /// server: xxx + /// port: xxx + /// ports: xxx + /// password: xxx + /// skip-cert-verify: xxx, + /// }, + /// { + /// name : hop_node_1_xxxx, + /// type: xxx + /// server: xxx + /// port: xxx + /// ports: xxx + /// password: xxx + /// skip-cert-verify: xxx, + /// dialer-proxy : "入口节点" + /// }, + /// { + /// name : 出口节点, + /// type: xxx + /// server: xxx + /// port: xxx + /// ports: xxx + /// password: xxx + /// skip-cert-verify: xxx, + /// dialer-proxy : "hop_node_1_xxxx" + /// } + /// ], + /// "proxy-groups" : [ + /// { + /// name : "proxy_chain", + /// type: "select", + /// proxies ["出口节点"] + /// } + /// ] + /// } + /// + /// 传入none 为删除 + pub fn update_proxy_chain_config(&mut self, proxy_chain_config: Option) { + if let Some(config) = self.config.as_mut() { + // 获取 默认第一的代理组的名字 + let proxy_group_name = + if let Some(Value::Sequence(proxy_groups)) = config.get("proxy-groups") { + if let Some(Value::Mapping(proxy_group)) = proxy_groups.first() { + if let Some(Value::String(proxy_group_name)) = proxy_group.get("name") { + proxy_group_name.to_string() + } else { + "".to_string() + } + } else { + "".to_string() + } + } else { + "".to_string() + }; + + if let Some(Value::Sequence(proxies)) = config.get_mut("proxies") { + proxies.iter_mut().for_each(|proxy| { + if let Some(proxy) = proxy.as_mapping_mut() + && proxy.get("dialer-proxy").is_some() + { + proxy.remove("dialer-proxy"); + } + }); + } + + // 清除proxy_chain代理组 + if let Some(Value::Sequence(proxy_groups)) = config.get_mut("proxy-groups") { + proxy_groups.retain(|proxy_group| { + !matches!(proxy_group.get("name").and_then(|n| n.as_str()), Some(name) if name== "proxy_chain") + }); + } + + // 清除rules + if let Some(Value::Sequence(rules)) = config.get_mut("rules") { + rules.retain(|rule| rule.as_str() != Some("MATCH,proxy_chain")); + rules.push(Value::String(format!("MATCH,{}", proxy_group_name))); + } + + // some 则写入新配置 + // 给proxy添加dialer-proxy字段 + // 第一个proxy不添加dialer-proxy + // 然后第二个开始,dialer-proxy为上一个元素的name + if let Some(Value::Sequence(dialer_proxies)) = proxy_chain_config { + let mut proxy_chain_group = Mapping::new(); + + if let Some(Value::Sequence(proxies)) = config.get_mut("proxies") { + for (i, dialer_proxy) in dialer_proxies.iter().enumerate() { + if let Some(Value::Mapping(proxy)) = proxies + .iter_mut() + .find(|proxy| proxy.get("name") == Some(dialer_proxy)) + { + if i != 0 + && let Some(dialer_proxy) = dialer_proxies.get(i - 1) + { + proxy.insert("dialer-proxy".into(), dialer_proxy.to_owned()); + } + if i == dialer_proxies.len() - 1 { + // 添加proxy-groups + proxy_chain_group + .insert("name".into(), Value::String("proxy_chain".into())); + proxy_chain_group + .insert("type".into(), Value::String("select".into())); + proxy_chain_group.insert( + "proxies".into(), + Value::Sequence(vec![dialer_proxy.to_owned()]), + ); + } + } + } + } + + if let Some(Value::Sequence(proxy_groups)) = config.get_mut("proxy-groups") { + proxy_groups.push(Value::Mapping(proxy_chain_group)); + } + + // 添加rules + if let Some(Value::Sequence(rules)) = config.get_mut("rules") + && let Ok(rule) = serde_yaml_ng::to_value("MATCH,proxy_chain") + { + rules.retain(|rule| { + rule.as_str() != Some(&format!("MATCH,{}", proxy_group_name)) + }); + rules.push(rule); + // *rules = vec![rule]; + } + } + } + } } diff --git a/src-tauri/src/ipc/general.rs b/src-tauri/src/ipc/general.rs index dd17a04d..e4bdebf2 100644 --- a/src-tauri/src/ipc/general.rs +++ b/src-tauri/src/ipc/general.rs @@ -278,7 +278,6 @@ impl IpcManager { let payload = serde_json::json!({ "name": proxy }); - match self.send_request("PUT", &url, Some(&payload)).await { Ok(_) => Ok(()), Err(e) => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 38cc66b4..a7412856 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -178,6 +178,8 @@ mod app_init { cmd::get_runtime_yaml, cmd::get_runtime_exists, cmd::get_runtime_logs, + cmd::get_runtime_proxy_chain_config, + cmd::update_proxy_chain_config_in_runtime, cmd::invoke_uwp_tool, cmd::copy_clash_env, cmd::get_proxies, diff --git a/src/components/proxy/proxy-chain.tsx b/src/components/proxy/proxy-chain.tsx new file mode 100644 index 00000000..bd30fd1f --- /dev/null +++ b/src/components/proxy/proxy-chain.tsx @@ -0,0 +1,586 @@ +import { useState, useCallback, useEffect, useRef } from "react"; +import { + Box, + Paper, + Typography, + IconButton, + Chip, + Alert, + useTheme, + Button, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useAppData } from "@/providers/app-data-provider"; +import { + updateProxyChainConfigInRuntime, + updateProxyAndSync, + getProxies, + closeAllConnections, +} from "@/services/cmds"; +import useSWR from "swr"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + Delete as DeleteIcon, + DragIndicator, + ClearAll, + Save, + Link, + LinkOff, +} from "@mui/icons-material"; + +interface ProxyChainItem { + id: string; + name: string; + type?: string; + delay?: number; +} + +interface ParsedChainConfig { + proxies?: Array<{ + name: string; + type: string; + [key: string]: any; + }>; +} + +interface ProxyChainProps { + proxyChain: ProxyChainItem[]; + onUpdateChain: (chain: ProxyChainItem[]) => void; + chainConfigData?: string | null; + onMarkUnsavedChanges?: () => void; +} + +interface SortableItemProps { + proxy: ProxyChainItem; + index: number; + onRemove: (id: string) => void; +} + +const SortableItem = ({ proxy, index, onRemove }: SortableItemProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: proxy.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( + + + + + + + + + {proxy.name} + + + {proxy.type && ( + + )} + + {proxy.delay !== undefined && ( + 0 ? `${proxy.delay}ms` : t("timeout") || "超时"} + size="small" + color={ + proxy.delay > 0 && proxy.delay < 200 + ? "success" + : proxy.delay > 0 && proxy.delay < 800 + ? "warning" + : "error" + } + sx={{ mr: 1, fontSize: "0.7rem", minWidth: 50 }} + /> + )} + + onRemove(proxy.id)} + sx={{ + color: theme.palette.error.main, + "&:hover": { + backgroundColor: theme.palette.error.light + "20", + }, + }} + > + + + + ); +}; + +export const ProxyChain = ({ + proxyChain, + onUpdateChain, + chainConfigData, + onMarkUnsavedChanges, +}: ProxyChainProps) => { + const theme = useTheme(); + const { t } = useTranslation(); + const { proxies } = useAppData(); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [isConnected, setIsConnected] = useState(false); + + // 获取当前代理信息以检查连接状态 + const { data: currentProxies, mutate: mutateProxies } = useSWR( + "getProxies", + getProxies, + { + revalidateOnFocus: true, + revalidateIfStale: true, + refreshInterval: 5000, // 每5秒刷新一次 + }, + ); + + // 检查连接状态 + useEffect(() => { + if (!currentProxies || proxyChain.length < 2) { + setIsConnected(false); + return; + } + + // 查找 proxy_chain 代理组 + const proxyChainGroup = currentProxies.groups.find( + (group) => group.name === "proxy_chain", + ); + if (!proxyChainGroup || !proxyChainGroup.now) { + setIsConnected(false); + return; + } + + // 获取用户配置的最后一个节点 + const lastNode = proxyChain[proxyChain.length - 1]; + + // 检查当前选中的代理是否是配置的最后一个节点 + if (proxyChainGroup.now === lastNode.name) { + setIsConnected(true); + } else { + setIsConnected(false); + } + }, [currentProxies, proxyChain]); + + // 监听链的变化,但排除从配置加载的情况 + const chainLengthRef = useRef(proxyChain.length); + useEffect(() => { + // 只有当链长度发生变化且不是初始加载时,才标记为未保存 + if ( + chainLengthRef.current !== proxyChain.length && + chainLengthRef.current !== 0 + ) { + setHasUnsavedChanges(true); + } + chainLengthRef.current = proxyChain.length; + }, [proxyChain.length]); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + if (active.id !== over?.id) { + const oldIndex = proxyChain.findIndex((item) => item.id === active.id); + const newIndex = proxyChain.findIndex((item) => item.id === over?.id); + + onUpdateChain(arrayMove(proxyChain, oldIndex, newIndex)); + setHasUnsavedChanges(true); + } + }, + [proxyChain, onUpdateChain], + ); + + const handleRemoveProxy = useCallback( + (id: string) => { + const newChain = proxyChain.filter((item) => item.id !== id); + onUpdateChain(newChain); + setHasUnsavedChanges(true); + }, + [proxyChain, onUpdateChain], + ); + + const handleClearAll = useCallback(() => { + onUpdateChain([]); + setHasUnsavedChanges(true); + }, [onUpdateChain]); + + const handleConnect = useCallback(async () => { + if (isConnected) { + // 如果已连接,则断开连接 + setIsConnecting(true); + try { + // 清空链式代理配置 + await updateProxyChainConfigInRuntime(null); + + // 切换到 DIRECT 模式断开代理连接 + // await updateProxyAndSync("GLOBAL", "DIRECT"); + + // 关闭所有连接 + await closeAllConnections(); + + // 刷新代理信息以更新连接状态 + mutateProxies(); + + // 清空链式代理配置UI + // onUpdateChain([]); + // setHasUnsavedChanges(false); + + // 强制更新连接状态 + setIsConnected(false); + } catch (error) { + console.error("Failed to disconnect from proxy chain:", error); + alert(t("Failed to disconnect from proxy chain") || "断开链式代理失败"); + } finally { + setIsConnecting(false); + } + return; + } + + if (proxyChain.length < 2) { + alert( + t("Chain proxy requires at least 2 nodes") || "链式代理至少需要2个节点", + ); + return; + } + + setIsConnecting(true); + try { + // 第一步:保存链式代理配置 + const chainProxies = proxyChain.map((node) => node.name); + console.log("Saving chain config:", chainProxies); + await updateProxyChainConfigInRuntime(chainProxies); + console.log("Chain configuration saved successfully"); + + // 第二步:连接到代理链的最后一个节点 + const lastNode = proxyChain[proxyChain.length - 1]; + console.log(`Connecting to proxy chain, last node: ${lastNode.name}`); + await updateProxyAndSync("proxy_chain", lastNode.name); + + // 刷新代理信息以更新连接状态 + mutateProxies(); + + // 清除未保存标记 + setHasUnsavedChanges(false); + + console.log("Successfully connected to proxy chain"); + } catch (error) { + console.error("Failed to connect to proxy chain:", error); + alert(t("Failed to connect to proxy chain") || "连接链式代理失败"); + } finally { + setIsConnecting(false); + } + }, [proxyChain, isConnected, t, mutateProxies]); + + const proxyChainRef = useRef(proxyChain); + const onUpdateChainRef = useRef(onUpdateChain); + + useEffect(() => { + proxyChainRef.current = proxyChain; + onUpdateChainRef.current = onUpdateChain; + }, [proxyChain, onUpdateChain]); + + // 处理链式代理配置数据 + useEffect(() => { + if (chainConfigData) { + try { + // Try to parse as YAML using dynamic import + import("js-yaml") + .then((yaml) => { + try { + const parsedConfig = yaml.load( + chainConfigData, + ) as ParsedChainConfig; + const chainItems = + parsedConfig?.proxies?.map((proxy, index: number) => ({ + id: `${proxy.name}_${Date.now()}_${index}`, + name: proxy.name, + type: proxy.type, + delay: undefined, + })) || []; + onUpdateChain(chainItems); + setHasUnsavedChanges(false); + } catch (parseError) { + console.error("Failed to parse YAML:", parseError); + onUpdateChain([]); + } + }) + .catch((importError) => { + // Fallback: try to parse as JSON if YAML is not available + console.warn( + "js-yaml not available, trying JSON parse:", + importError, + ); + try { + const parsedConfig = JSON.parse( + chainConfigData, + ) as ParsedChainConfig; + const chainItems = + parsedConfig?.proxies?.map((proxy, index: number) => ({ + id: `${proxy.name}_${Date.now()}_${index}`, + name: proxy.name, + type: proxy.type, + delay: undefined, + })) || []; + onUpdateChain(chainItems); + setHasUnsavedChanges(false); + } catch (jsonError) { + console.error("Failed to parse as JSON either:", jsonError); + onUpdateChain([]); + } + }); + } catch (error) { + console.error("Failed to process chain config data:", error); + onUpdateChain([]); + } + } else if (chainConfigData === "") { + // Empty string means no proxies available, show empty state + onUpdateChain([]); + setHasUnsavedChanges(false); + } + }, [chainConfigData, onUpdateChain]); + + // 定时更新延迟数据 + useEffect(() => { + if (!proxies?.records) return; + + const updateDelays = () => { + const currentChain = proxyChainRef.current; + if (currentChain.length === 0) return; + + const updatedChain = currentChain.map((item) => { + const proxyRecord = proxies.records[item.name]; + if ( + proxyRecord && + proxyRecord.history && + proxyRecord.history.length > 0 + ) { + const latestDelay = + proxyRecord.history[proxyRecord.history.length - 1].delay; + return { ...item, delay: latestDelay }; + } + return item; + }); + + // 只有在延迟数据确实发生变化时才更新 + const hasChanged = updatedChain.some( + (item, index) => item.delay !== currentChain[index]?.delay, + ); + + if (hasChanged) { + onUpdateChainRef.current(updatedChain); + } + }; + + // 立即更新一次延迟 + updateDelays(); + + // 设置定时器,每5秒更新一次延迟 + const interval = setInterval(updateDelays, 5000); + + return () => clearInterval(interval); + }, [proxies?.records]); // 只依赖proxies.records + + return ( + + + {t("Chain Proxy Config")} + + {proxyChain.length > 0 && ( + { + updateProxyChainConfigInRuntime(null); + onUpdateChain([]); + setHasUnsavedChanges(false); + }} + sx={{ + color: theme.palette.error.main, + "&:hover": { + backgroundColor: theme.palette.error.light + "20", + }, + }} + title={t("Delete Chain Config") || "删除链式配置"} + > + + + )} + + + + + + {proxyChain.length === 1 + ? t( + "Chain proxy requires at least 2 nodes. Please add one more node.", + ) || "链式代理至少需要2个节点,请再添加一个节点。" + : t("Click nodes in order to add to proxy chain") || + "按顺序点击节点添加到代理链中"} + + + + {proxyChain.length === 0 ? ( + + {t("No proxy chain configured")} + + ) : ( + + proxy.id)} + strategy={verticalListSortingStrategy} + > + + {proxyChain.map((proxy, index) => ( + + ))} + + + + )} + + + ); +}; diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 44318471..ce521c17 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -10,190 +10,37 @@ import { ProxyRender } from "./proxy-render"; import delayManager from "@/services/delay"; import { useTranslation } from "react-i18next"; import { ScrollTopButton } from "../layout/scroll-top-button"; -import { Box, styled } from "@mui/material"; +import { Box, styled, Snackbar, Alert } from "@mui/material"; import { memo } from "react"; import { createPortal } from "react-dom"; - -// 将选择器组件抽离出来,避免主组件重渲染时重复创建样式 -const AlphabetSelector = styled(Box)(({ theme }) => ({ - position: "fixed", - right: 4, - top: "50%", - transform: "translateY(-50%)", - display: "flex", - flexDirection: "column", - background: "transparent", - zIndex: 1000, - gap: "2px", - // padding: "4px 2px", - willChange: "transform", - "&:hover": { - background: theme.palette.background.paper, - boxShadow: theme.shadows[2], - borderRadius: "8px", - }, - "& .scroll-container": { - overflow: "hidden", - maxHeight: "inherit", - willChange: "transform", - }, - "& .letter-container": { - display: "flex", - flexDirection: "column", - gap: "2px", - transition: "transform 0.2s ease", - willChange: "transform", - }, - "& .letter": { - padding: "1px 4px", - fontSize: "12px", - cursor: "pointer", - fontFamily: - "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif", - color: theme.palette.text.secondary, - position: "relative", - width: "1.5em", - height: "1.5em", - display: "flex", - alignItems: "center", - justifyContent: "center", - transition: "all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)", - transform: "scale(1) translateZ(0)", - backfaceVisibility: "hidden", - borderRadius: "6px", - "&:hover": { - color: theme.palette.primary.main, - transform: "scale(1.4) translateZ(0)", - backgroundColor: theme.palette.action.hover, - }, - }, -})); - -// 创建一个单独的 Tooltip 组件 -const Tooltip = styled("div")(({ theme }) => ({ - position: "fixed", - background: theme.palette.background.paper, - padding: "4px 8px", - borderRadius: "6px", - boxShadow: theme.shadows[3], - whiteSpace: "nowrap", - fontSize: "16px", - color: theme.palette.text.primary, - pointerEvents: "none", - "&::after": { - content: '""', - position: "absolute", - right: "-4px", - top: "50%", - transform: "translateY(-50%)", - width: 0, - height: 0, - borderTop: "4px solid transparent", - borderBottom: "4px solid transparent", - borderLeft: `4px solid ${theme.palette.background.paper}`, - }, -})); - -// 抽离字母选择器子组件 -const LetterItem = memo( - ({ - name, - onClick, - getFirstChar, - enableAutoScroll = true, - }: { - name: string; - onClick: (name: string) => void; - getFirstChar: (str: string) => string; - enableAutoScroll?: boolean; - }) => { - const [showTooltip, setShowTooltip] = useState(false); - const letterRef = useRef(null); - const [tooltipPosition, setTooltipPosition] = useState({ - top: 0, - right: 0, - }); - const hoverTimeoutRef = useRef>(undefined); - - const updateTooltipPosition = useCallback(() => { - if (!letterRef.current) return; - const rect = letterRef.current.getBoundingClientRect(); - setTooltipPosition({ - top: rect.top + rect.height / 2, - right: window.innerWidth - rect.left + 8, - }); - }, []); - - useEffect(() => { - if (showTooltip) { - updateTooltipPosition(); - } - }, [showTooltip, updateTooltipPosition]); - - const handleMouseEnter = useCallback(() => { - setShowTooltip(true); - // 只有在启用自动滚动时才触发滚动 - if (enableAutoScroll) { - // 添加 100ms 的延迟,避免鼠标快速划过时触发滚动 - hoverTimeoutRef.current = setTimeout(() => { - onClick(name); - }, 100); - } - }, [name, onClick, enableAutoScroll]); - - const handleMouseLeave = useCallback(() => { - setShowTooltip(false); - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - } - }, []); - - useEffect(() => { - return () => { - if (hoverTimeoutRef.current) { - clearTimeout(hoverTimeoutRef.current); - } - }; - }, []); - - return ( - <> -
onClick(name)} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - > - {getFirstChar(name)} -
- {showTooltip && - createPortal( - - {name} - , - document.body, - )} - - ); - }, -); +import { ProxyChain } from "./proxy-chain"; interface Props { mode: string; + isChainMode?: boolean; + chainConfigData?: string | null; +} + +interface ProxyChainItem { + id: string; + name: string; + type?: string; + delay?: number; } export const ProxyGroups = (props: Props) => { const { t } = useTranslation(); - const { mode } = props; + const { mode, isChainMode = false, chainConfigData } = props; + const [proxyChain, setProxyChain] = useState([]); + const [duplicateWarning, setDuplicateWarning] = useState<{ + open: boolean; + message: string; + }>({ open: false, message: "" }); - const { renderList, onProxies, onHeadState } = useRenderList(mode); + const { renderList, onProxies, onHeadState } = useRenderList( + mode, + isChainMode, + ); const { verge } = useVerge(); @@ -208,46 +55,12 @@ export const ProxyGroups = (props: Props) => { }, }); - // 获取自动滚动开关状态,默认为 true - const enableAutoScroll = verge?.enable_hover_jump_navigator ?? true; const timeout = verge?.default_latency_timeout || 10000; const virtuosoRef = useRef(null); const scrollPositionRef = useRef>({}); const [showScrollTop, setShowScrollTop] = useState(false); const scrollerRef = useRef(null); - const letterContainerRef = useRef(null); - const alphabetSelectorRef = useRef(null); - const [maxHeight, setMaxHeight] = useState("auto"); - - // 使用useMemo缓存字母索引数据 - const { groupFirstLetters, letterIndexMap } = useMemo(() => { - const letters = new Set(); - const indexMap: Record = {}; - - renderList.forEach((item, index) => { - if (item.type === 0) { - const fullName = item.group.name; - letters.add(fullName); - if (!(fullName in indexMap)) { - indexMap[fullName] = index; - } - } - }); - - return { - groupFirstLetters: Array.from(letters), - letterIndexMap: indexMap, - }; - }, [renderList]); - - // 缓存getFirstChar函数 - const getFirstChar = useCallback((str: string) => { - const regex = - /\p{Regional_Indicator}{2}|\p{Extended_Pictographic}|\p{L}|\p{N}|./u; - const match = str.match(regex); - return match ? match[0] : str.charAt(0); - }, []); // 从 localStorage 恢复滚动位置 useEffect(() => { @@ -323,28 +136,49 @@ export const ProxyGroups = (props: Props) => { saveScrollPosition(0); }, [saveScrollPosition]); - // 处理字母点击,使用useCallback - const handleLetterClick = useCallback( - (name: string) => { - const index = letterIndexMap[name]; - if (index !== undefined) { - virtuosoRef.current?.scrollToIndex({ - index, - align: "start", - behavior: "smooth", - }); - } - }, - [letterIndexMap], - ); + // 关闭重复节点警告 + const handleCloseDuplicateWarning = useCallback(() => { + setDuplicateWarning({ open: false, message: "" }); + }, []); const handleChangeProxy = useCallback( (group: IProxyGroupItem, proxy: IProxyItem) => { + if (isChainMode) { + // 使用函数式更新来避免状态延迟问题 + setProxyChain((prev) => { + // 检查是否已经存在相同名称的代理,防止重复添加 + if (prev.some((item) => item.name === proxy.name)) { + const warningMessage = t("Proxy node already exists in chain"); + setDuplicateWarning({ + open: true, + message: warningMessage, + }); + return prev; // 返回原来的状态,不做任何更改 + } + + // 安全获取延迟数据,如果没有延迟数据则设为 undefined + const delay = + proxy.history && proxy.history.length > 0 + ? proxy.history[proxy.history.length - 1].delay + : undefined; + + const chainItem: ProxyChainItem = { + id: `${proxy.name}_${Date.now()}`, + name: proxy.name, + type: proxy.type, + delay: delay, + }; + + return [...prev, chainItem]; + }); + return; + } + if (!["Selector", "URLTest", "Fallback"].includes(group.type)) return; handleProxyGroupChange(group, proxy); }, - [handleProxyGroupChange], + [handleProxyGroupChange, isChainMode, t], ); // 测全部延迟 @@ -417,74 +251,73 @@ export const ProxyGroups = (props: Props) => { } }; - // 添加滚轮事件处理函数 - 改进为只在悬停时触发 - const handleWheel = useCallback((e: WheelEvent) => { - // 只有当鼠标在字母选择器上时才处理滚轮事件 - if (!alphabetSelectorRef.current?.contains(e.target as Node)) return; - - e.preventDefault(); - if (!letterContainerRef.current) return; - - const container = letterContainerRef.current; - const scrollAmount = e.deltaY; - const currentTransform = new WebKitCSSMatrix(container.style.transform); - const currentY = currentTransform.m42 || 0; - - const containerHeight = container.getBoundingClientRect().height; - const parentHeight = - container.parentElement?.getBoundingClientRect().height || 0; - const maxScroll = Math.max(0, containerHeight - parentHeight); - - let newY = currentY - scrollAmount; - newY = Math.min(0, Math.max(-maxScroll, newY)); - - container.style.transform = `translateY(${newY}px)`; - }, []); - - // 添加和移除滚轮事件监听 - useEffect(() => { - const container = letterContainerRef.current?.parentElement; - if (container) { - container.addEventListener("wheel", handleWheel, { passive: false }); - return () => { - container.removeEventListener("wheel", handleWheel); - }; - } - }, [handleWheel]); - - // 监听窗口大小变化 - // layout effect runs before paint - useEffect(() => { - // 添加窗口大小变化监听和最大高度计算 - const updateMaxHeight = () => { - if (!alphabetSelectorRef.current) return; - - const windowHeight = window.innerHeight; - const bottomMargin = 60; // 底部边距 - const topMargin = bottomMargin * 2; // 顶部边距是底部的2倍 - const availableHeight = windowHeight - (topMargin + bottomMargin); - - // 调整选择器的位置,使其偏下 - const offsetPercentage = - (((topMargin - bottomMargin) / windowHeight) * 100) / 2; - alphabetSelectorRef.current.style.top = `calc(48% + ${offsetPercentage}vh)`; - - setMaxHeight(`${availableHeight}px`); - }; - - updateMaxHeight(); - - window.addEventListener("resize", updateMaxHeight); - - return () => { - window.removeEventListener("resize", updateMaxHeight); - }; - }, []); - if (mode === "direct") { return ; } + if (isChainMode) { + return ( + <> + + + { + scrollerRef.current = ref as Element; + }} + components={{ + Footer: () =>
, + }} + initialScrollTop={scrollPositionRef.current[mode]} + computeItemKey={(index) => renderList[index].key} + itemContent={(index) => ( + + )} + /> + + + + + + + + + + + {duplicateWarning.message} + + + + ); + } + return (
{ )} /> - - -
-
- {groupFirstLetters.map((name) => ( - - ))} -
-
-
); }; diff --git a/src/components/proxy/proxy-render.tsx b/src/components/proxy/proxy-render.tsx index d8022a93..6d7c04bf 100644 --- a/src/components/proxy/proxy-render.tsx +++ b/src/components/proxy/proxy-render.tsx @@ -28,6 +28,7 @@ import { useTranslation } from "react-i18next"; interface RenderProps { item: IRenderItem; indent: boolean; + isChainMode?: boolean; onLocation: (group: IRenderItem["group"]) => void; onCheckAll: (groupName: string) => void; onHeadState: (groupName: string, patch: Partial) => void; @@ -39,8 +40,15 @@ interface RenderProps { export const ProxyRender = (props: RenderProps) => { const { t } = useTranslation(); - const { indent, item, onLocation, onCheckAll, onHeadState, onChangeProxy } = - props; + const { + indent, + item, + onLocation, + onCheckAll, + onHeadState, + onChangeProxy, + isChainMode = false, + } = props; const { type, group, headState, proxy, proxyCol } = item; const { verge } = useVerge(); const enable_group_icon = verge?.enable_group_icon ?? true; diff --git a/src/components/proxy/use-render-list.ts b/src/components/proxy/use-render-list.ts index 1e7f6375..6db752c1 100644 --- a/src/components/proxy/use-render-list.ts +++ b/src/components/proxy/use-render-list.ts @@ -8,6 +8,9 @@ import { type HeadState, } from "./use-head-state"; import { useAppData } from "@/providers/app-data-provider"; +import useSWR from "swr"; +import { getRuntimeConfig } from "@/services/cmds"; +import delayManager from "@/services/delay"; // 定义代理项接口 interface IProxyItem { @@ -88,13 +91,23 @@ const groupProxies = (list: T[], size: number): T[][] => { }, [] as T[][]); }; -export const useRenderList = (mode: string) => { +export const useRenderList = (mode: string, isChainMode?: boolean) => { // 使用全局数据提供者 const { proxies: proxiesData, refreshProxy } = useAppData(); const { verge } = useVerge(); const { width } = useWindowWidth(); const [headStates, setHeadState] = useHeadStateNew(); + // 获取运行时配置用于链式代理模式 + const { data: runtimeConfig } = useSWR( + isChainMode ? "getRuntimeConfig" : null, + getRuntimeConfig, + { + revalidateOnFocus: false, + revalidateIfStale: true, + }, + ); + // 计算列数 const col = useMemo( () => calculateColumns(width, verge?.proxy_layout_column || 6), @@ -115,10 +128,116 @@ export const useRenderList = (mode: string) => { } }, [proxiesData, mode, refreshProxy]); + // 链式代理模式节点自动计算延迟 + useEffect(() => { + if (!isChainMode || !runtimeConfig) return; + + const allProxies: IProxyItem[] = Object.values( + (runtimeConfig as any).proxies || {}, + ); + if (allProxies.length === 0) return; + + // 设置组监听器,当有延迟更新时自动刷新 + const groupListener = () => { + console.log("[ChainMode] 延迟更新,刷新UI"); + refreshProxy(); + }; + + delayManager.setGroupListener("chain-mode", groupListener); + + const calculateDelays = async () => { + try { + const timeout = verge?.default_latency_timeout || 10000; + const proxyNames = allProxies.map((proxy) => proxy.name); + + console.log(`[ChainMode] 开始计算 ${proxyNames.length} 个节点的延迟`); + + // 使用 delayManager 计算延迟,每个节点计算完成后会自动触发监听器刷新界面 + delayManager.checkListDelay(proxyNames, "chain-mode", timeout); + } catch (error) { + console.error("Failed to calculate delays for chain mode:", error); + } + }; + + // 延迟执行避免阻塞 + const handle = setTimeout(calculateDelays, 100); + + return () => { + clearTimeout(handle); + // 清理组监听器 + delayManager.removeGroupListener("chain-mode"); + }; + }, [ + isChainMode, + runtimeConfig, + verge?.default_latency_timeout, + refreshProxy, + ]); + // 处理渲染列表 const renderList: IRenderItem[] = useMemo(() => { if (!proxiesData) return []; + // 链式代理模式下,从运行时配置读取所有 proxies + if (isChainMode && runtimeConfig) { + // 从运行时配置直接获取 proxies 列表 (需要类型断言) + const allProxies: IProxyItem[] = Object.values( + (runtimeConfig as any).proxies || {}, + ); + + // 为每个节点获取延迟信息 + const proxiesWithDelay = allProxies.map((proxy) => { + const delay = delayManager.getDelay(proxy.name, "chain-mode"); + return { + ...proxy, + // 如果delayManager有延迟数据,更新history + history: + delay >= 0 + ? [{ time: new Date().toISOString(), delay }] + : proxy.history || [], + }; + }); + + // 创建一个虚拟的组来容纳所有节点 + const virtualGroup: ProxyGroup = { + name: "All Proxies", + type: "Selector", + udp: false, + xudp: false, + tfo: false, + mptcp: false, + smux: false, + history: [], + now: "", + all: proxiesWithDelay, + }; + + // 返回节点列表(不显示组头) + if (col > 1) { + return groupProxies(proxiesWithDelay, col).map( + (proxyCol, colIndex) => ({ + type: 4, + key: `chain-col-${colIndex}`, + group: virtualGroup, + headState: DEFAULT_STATE, + col, + proxyCol, + provider: proxyCol[0]?.provider, + }), + ); + } else { + return proxiesWithDelay.map((proxy) => ({ + type: 2, + key: `chain-${proxy.name}`, + group: virtualGroup, + proxy, + headState: DEFAULT_STATE, + provider: proxy.provider, + })); + } + } + + // 正常模式的渲染逻辑 const useRule = mode === "rule" || mode === "script"; const renderGroups = useRule && proxiesData.groups.length @@ -190,7 +309,7 @@ export const useRenderList = (mode: string) => { if (!useRule) return retList.slice(1); return retList.filter((item: IRenderItem) => !item.group.hidden); - }, [headStates, proxiesData, mode, col]); + }, [headStates, proxiesData, mode, col, isChainMode, runtimeConfig]); return { renderList, diff --git a/src/locales/ar.json b/src/locales/ar.json index ea131dbe..b7f25860 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -31,6 +31,7 @@ "rule": "قاعدة", "global": "عالمي", "direct": "مباشر", + "Chain Proxy": "🔗 بروكسي السلسلة", "script": "سكريبت", "locate": "الموقع", "Delay check": "فحص التأخير", diff --git a/src/locales/de.json b/src/locales/de.json index 389658d7..fba723cc 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -32,6 +32,7 @@ "rule": "Regel", "global": "Global", "direct": "Direktverbindung", + "Chain Proxy": "🔗 Ketten-Proxy", "script": "Skript", "locate": "Aktueller Knoten", "Delay check": "Latenztest", diff --git a/src/locales/en.json b/src/locales/en.json index b747d5a7..7a4d5132 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -26,6 +26,11 @@ "Label-Settings": "Settings", "Proxies": "Proxies", "Proxy Groups": "Proxy Groups", + "Node Pool": "Node Pool", + "Connect": "Connect", + "Connecting...": "Connecting...", + "Disconnect": "Disconnect", + "Failed to connect to proxy chain": "Failed to connect to proxy chain", "Proxy Provider": "Proxy Provider", "Proxy Count": "Proxy Count", "Update All": "Update All", @@ -33,6 +38,13 @@ "rule": "rule", "global": "global", "direct": "direct", + "Chain Proxy": "🔗 Chain Proxy", + "Chain Proxy Config": "Chain Proxy Config", + "Click nodes in order to add to proxy chain": "Click nodes in order to add to proxy chain", + "No proxy chain configured": "No proxy chain configured", + "Proxy Order": "Proxy Order", + "timeout": "Timeout", + "Clear All": "Clear All", "script": "script", "locate": "locate", "Delay check": "Delay check", @@ -664,5 +676,6 @@ "Failed to save configuration": "Failed to save configuration", "Controller address copied to clipboard": "Controller address copied to clipboard", "Secret copied to clipboard": "Secret copied to clipboard", - "Saving...": "Saving..." + "Saving...": "Saving...", + "Proxy node already exists in chain": "Proxy node already exists in chain" } diff --git a/src/locales/es.json b/src/locales/es.json index 5c7931c2..6ced735b 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -32,6 +32,7 @@ "rule": "Regla", "global": "Global", "direct": "Conexión directa", + "Chain Proxy": "🔗 Proxy en cadena", "script": "Script", "locate": "Nodo actual", "Delay check": "Prueba de latencia", diff --git a/src/locales/fa.json b/src/locales/fa.json index 1b12d56d..858bf572 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -31,6 +31,7 @@ "rule": "قانون", "global": "جهانی", "direct": "مستقیم", + "Chain Proxy": "🔗 پراکسی زنجیره‌ای", "script": "اسکریپت", "locate": "موقعیت", "Delay check": "بررسی تأخیر", diff --git a/src/locales/id.json b/src/locales/id.json index 4aeb4fc8..9b9d9127 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -63,6 +63,7 @@ "rule": "aturan", "global": "global", "direct": "langsung", + "Chain Proxy": "🔗 Proxy Rantai", "script": "skrip", "locate": "Lokasi", "Delay check": "Periksa Keterlambatan", diff --git a/src/locales/jp.json b/src/locales/jp.json index c9e4dd12..92488c70 100644 --- a/src/locales/jp.json +++ b/src/locales/jp.json @@ -32,6 +32,7 @@ "rule": "ルール", "global": "グローバル", "direct": "直接接続", + "Chain Proxy": "🔗 チェーンプロキシ", "script": "スクリプト", "locate": "現在のノード", "Delay check": "遅延テスト", diff --git a/src/locales/ko.json b/src/locales/ko.json index a93171f0..ab0f98bf 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -33,6 +33,7 @@ "rule": "규칙", "global": "전역", "direct": "직접", + "Chain Proxy": "🔗 체인 프록시", "script": "스크립트", "locate": "로케이트", "Delay check": "지연 확인", @@ -126,7 +127,6 @@ "Lazy": "지연 로딩", "Timeout": "타임아웃", "Max Failed Times": "최대 실패 횟수", - "Interface Name": "인터페이스 이름", "Routing Mark": "라우팅 마크", "Include All": "모든 프록시 및 제공자 포함", "Include All Providers": "모든 제공자 포함", diff --git a/src/locales/ru.json b/src/locales/ru.json index 0bdeab48..cbd6e6b6 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -32,6 +32,13 @@ "rule": "правила", "global": "глобальный", "direct": "прямой", + "Chain Proxy": "🔗 Цепной прокси", + "Chain Proxy Config": "Конфигурация цепочки прокси", + "Click nodes in order to add to proxy chain": "Нажимайте узлы по порядку, чтобы добавить в цепочку прокси", + "No proxy chain configured": "Цепочка прокси не настроена", + "Proxy Order": "Порядок прокси", + "timeout": "Тайм-аут", + "Clear All": "Очистить всё", "script": "скриптовый", "locate": "Местоположение", "Delay check": "Проверка задержки", diff --git a/src/locales/tr.json b/src/locales/tr.json index bc24c32d..c4e2c886 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -33,6 +33,7 @@ "rule": "kural", "global": "küresel", "direct": "doğrudan", + "Chain Proxy": "🔗 Zincir Proxy", "script": "betik", "locate": "konum", "Delay check": "Gecikme kontrolü", diff --git a/src/locales/tt.json b/src/locales/tt.json index 2dbfa84a..088e1241 100644 --- a/src/locales/tt.json +++ b/src/locales/tt.json @@ -31,6 +31,7 @@ "rule": "кагыйдә", "global": "глобаль", "direct": "туры", + "Chain Proxy": "🔗 Чылбыр прокси", "script": "скриптлы", "locate": "Урын", "Delay check": "Задержканы тикшерү", diff --git a/src/locales/zh.json b/src/locales/zh.json index c7a522e4..d94fff83 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -26,6 +26,11 @@ "Label-Settings": "设 置", "Proxies": "代理", "Proxy Groups": "代理组", + "Node Pool": "节点池", + "Connect": "连接", + "Connecting...": "连接中...", + "Disconnect": "断开", + "Failed to connect to proxy chain": "连接链式代理失败", "Proxy Provider": "代理集合", "Proxy Count": "节点数量", "Update All": "更新全部", @@ -33,6 +38,13 @@ "rule": "规则", "global": "全局", "direct": "直连", + "Chain Proxy": "🔗 链式代理", + "Chain Proxy Config": "代理链配置", + "Click nodes in order to add to proxy chain": "顺序点击节点添加到代理链中", + "No proxy chain configured": "暂无代理链配置", + "Proxy Order": "代理顺序", + "timeout": "超时", + "Clear All": "清除全部", "script": "脚本", "locate": "当前节点", "Delay check": "延迟测试", @@ -664,5 +676,6 @@ "Failed to save configuration": "配置保存失败", "Controller address copied to clipboard": "控制器地址已复制到剪贴板", "Secret copied to clipboard": "访问密钥已复制到剪贴板", - "Saving...": "保存中..." + "Saving...": "保存中...", + "Proxy node already exists in chain": "该节点已在链式代理表中" } diff --git a/src/locales/zhtw.json b/src/locales/zhtw.json index 9933db04..67d8e181 100644 --- a/src/locales/zhtw.json +++ b/src/locales/zhtw.json @@ -25,6 +25,11 @@ "Label-Unlock": "測 試", "Label-Settings": "設 置", "Proxy Groups": "代理組", + "Node Pool": "節點池", + "Connect": "連接", + "Connecting...": "連接中...", + "Disconnect": "斷開", + "Failed to connect to proxy chain": "連接鏈式代理失敗", "Proxy Provider": "代理集合", "Proxy Count": "節點數量", "Update All": "更新全部", @@ -33,6 +38,7 @@ "global": "全局", "direct": "直連", "script": "腳本", + "Chain Proxy": "🔗 鏈式代理", "locate": "當前節點", "Delay check": "延遲測試", "Sort by default": "默認排序", diff --git a/src/pages/proxies.tsx b/src/pages/proxies.tsx index 7c372e18..c4e1f640 100644 --- a/src/pages/proxies.tsx +++ b/src/pages/proxies.tsx @@ -1,9 +1,14 @@ import useSWR from "swr"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useLockFn } from "ahooks"; import { useTranslation } from "react-i18next"; import { Box, Button, ButtonGroup } from "@mui/material"; -import { closeAllConnections, getClashConfig } from "@/services/cmds"; +import { + closeAllConnections, + getClashConfig, + getRuntimeProxyChainConfig, + updateProxyChainConfigInRuntime, +} from "@/services/cmds"; import { patchClashMode } from "@/services/cmds"; import { useVerge } from "@/hooks/use-verge"; import { BasePage } from "@/components/base"; @@ -13,6 +18,18 @@ import { ProviderButton } from "@/components/proxy/provider-button"; const ProxyPage = () => { const { t } = useTranslation(); + // 从 localStorage 恢复链式代理按钮状态 + const [isChainMode, setIsChainMode] = useState(() => { + try { + const saved = localStorage.getItem("proxy-chain-mode-enabled"); + return saved === "true"; + } catch { + return false; + } + }); + + const [chainConfigData, setChainConfigData] = useState(null); + const { data: clashConfig, mutate: mutateClash } = useSWR( "getClashConfig", getClashConfig, @@ -39,6 +56,45 @@ const ProxyPage = () => { mutateClash(); }); + const onToggleChainMode = useLockFn(async () => { + const newChainMode = !isChainMode; + + if (!newChainMode) { + // 退出链式代理模式时,清除链式代理配置 + try { + console.log("Exiting chain mode, clearing chain configuration"); + await updateProxyChainConfigInRuntime(null); + console.log("Chain configuration cleared successfully"); + } catch (error) { + console.error("Failed to clear chain configuration:", error); + } + } + + setIsChainMode(newChainMode); + + // 保存链式代理按钮状态到 localStorage + localStorage.setItem("proxy-chain-mode-enabled", newChainMode.toString()); + }); + + // 当开启链式代理模式时,获取配置数据 + useEffect(() => { + if (isChainMode) { + const fetchChainConfig = async () => { + try { + const configData = await getRuntimeProxyChainConfig(); + setChainConfigData(configData || ""); + } catch (error) { + console.error("Failed to get runtime proxy chain config:", error); + setChainConfigData(""); + } + }; + + fetchChainConfig(); + } else { + setChainConfigData(null); + } + }, [isChainMode]); + useEffect(() => { if (curMode && !modeList.includes(curMode)) { onChangeMode("rule"); @@ -49,7 +105,7 @@ const ProxyPage = () => { @@ -66,10 +122,23 @@ const ProxyPage = () => { ))} + + } > - + ); }; diff --git a/src/providers/chain-proxy-provider.tsx b/src/providers/chain-proxy-provider.tsx new file mode 100644 index 00000000..ca39d58c --- /dev/null +++ b/src/providers/chain-proxy-provider.tsx @@ -0,0 +1,48 @@ +import React, { createContext, useContext, useState, useCallback } from "react"; + +interface ChainProxyContextType { + isChainMode: boolean; + setChainMode: (isChain: boolean) => void; + chainConfigData: string | null; + setChainConfigData: (data: string | null) => void; +} + +const ChainProxyContext = createContext(null); + +export const ChainProxyProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [isChainMode, setIsChainMode] = useState(false); + const [chainConfigData, setChainConfigData] = useState(null); + + const setChainMode = useCallback((isChain: boolean) => { + setIsChainMode(isChain); + }, []); + + const setChainConfigDataCallback = useCallback((data: string | null) => { + setChainConfigData(data); + }, []); + + return ( + + {children} + + ); +}; + +export const useChainProxy = () => { + const context = useContext(ChainProxyContext); + if (!context) { + throw new Error("useChainProxy must be used within a ChainProxyProvider"); + } + return context; +}; diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 18e0c552..fc875aff 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -86,6 +86,16 @@ export async function getRuntimeLogs() { return invoke>("get_runtime_logs"); } +export async function getRuntimeProxyChainConfig() { + return invoke("get_runtime_proxy_chain_config"); +} + +export async function updateProxyChainConfigInRuntime(proxyChainConfig: any) { + return invoke("update_proxy_chain_config_in_runtime", { + proxyChainConfig, + }); +} + export async function patchClashConfig(payload: Partial) { return invoke("patch_clash_config", { payload }); }