Add Func 链式代理 (#4624)

* 添加链式代理gui和语言支持
在Iruntime中添跟新链式代理配置方法
同时添加了cmd

* 修复读取运行时代理链配置文件bug

* t

* 完成链式代理配置构造

* 修复获取链式代理运行时配置的bug

* 完整的链式代理功能
This commit is contained in:
Junkai W.
2025-09-15 07:44:54 +08:00
committed by GitHub
parent a1f468202f
commit f2073a2f83
25 changed files with 1246 additions and 316 deletions

View File

@@ -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,

View File

@@ -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<Option<Mapping>> {
pub async fn get_runtime_yaml() -> CmdResult<String> {
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<Vec<String>> {
pub async fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String)>>> {
Ok(Config::runtime().await.latest_ref().chain_logs.clone())
}
/// 读取运行时链式代理配置
#[tauri::command]
pub async fn get_runtime_proxy_chain_config() -> CmdResult<String> {
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::<Vec<serde_yaml_ng::Value>>();
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<String, Vec<serde_yaml_ng::Value>> = 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<serde_yaml_ng::Value>,
) -> 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(())
}

View File

@@ -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<Value>) {
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];
}
}
}
}
}

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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 (
<Box
ref={setNodeRef}
style={style}
sx={{
mb: 1,
display: "flex",
alignItems: "center",
p: 1,
backgroundColor: isDragging
? theme.palette.action.selected
: theme.palette.background.default,
borderRadius: 1,
border: `1px solid ${theme.palette.divider}`,
boxShadow: isDragging ? theme.shadows[4] : theme.shadows[1],
transition: "box-shadow 0.2s, background-color 0.2s",
}}
>
<Box
{...attributes}
{...listeners}
sx={{
display: "flex",
alignItems: "center",
mr: 1,
color: theme.palette.text.secondary,
cursor: "grab",
"&:active": {
cursor: "grabbing",
},
}}
>
<DragIndicator />
</Box>
<Chip
label={`${index + 1}`}
size="small"
color="primary"
sx={{ mr: 1, minWidth: 32 }}
/>
<Typography
variant="body2"
sx={{
flex: 1,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{proxy.name}
</Typography>
{proxy.type && (
<Chip
label={proxy.type}
size="small"
variant="outlined"
sx={{ mr: 1 }}
/>
)}
{proxy.delay !== undefined && (
<Chip
label={proxy.delay > 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 }}
/>
)}
<IconButton
size="small"
onClick={() => onRemove(proxy.id)}
sx={{
color: theme.palette.error.main,
"&:hover": {
backgroundColor: theme.palette.error.light + "20",
},
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
);
};
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 (
<Paper
elevation={1}
sx={{
height: "100%",
p: 2,
display: "flex",
flexDirection: "column",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<Typography variant="h6">{t("Chain Proxy Config")}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{proxyChain.length > 0 && (
<IconButton
size="small"
onClick={() => {
updateProxyChainConfigInRuntime(null);
onUpdateChain([]);
setHasUnsavedChanges(false);
}}
sx={{
color: theme.palette.error.main,
"&:hover": {
backgroundColor: theme.palette.error.light + "20",
},
}}
title={t("Delete Chain Config") || "删除链式配置"}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
<Button
size="small"
variant="contained"
startIcon={isConnected ? <LinkOff /> : <Link />}
onClick={handleConnect}
disabled={isConnecting || proxyChain.length < 2}
color={isConnected ? "error" : "success"}
sx={{
minWidth: 90,
}}
title={
proxyChain.length < 2
? t("Chain proxy requires at least 2 nodes") ||
"链式代理至少需要2个节点"
: undefined
}
>
{isConnecting
? t("Connecting...") || "连接中..."
: isConnected
? t("Disconnect") || "断开"
: t("Connect") || "连接"}
</Button>
</Box>
</Box>
<Alert
severity={proxyChain.length === 1 ? "warning" : "info"}
sx={{ mb: 2 }}
>
{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") ||
"按顺序点击节点添加到代理链中"}
</Alert>
<Box sx={{ flex: 1, overflow: "auto" }}>
{proxyChain.length === 0 ? (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: theme.palette.text.secondary,
}}
>
<Typography>{t("No proxy chain configured")}</Typography>
</Box>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={proxyChain.map((proxy) => proxy.id)}
strategy={verticalListSortingStrategy}
>
<Box
sx={{
borderRadius: 1,
minHeight: 60,
p: 1,
}}
>
{proxyChain.map((proxy, index) => (
<SortableItem
key={proxy.id}
proxy={proxy}
index={index}
onRemove={handleRemoveProxy}
/>
))}
</Box>
</SortableContext>
</DndContext>
)}
</Box>
</Paper>
);
};

View File

@@ -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<HTMLDivElement>(null);
const [tooltipPosition, setTooltipPosition] = useState({
top: 0,
right: 0,
});
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout>>(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 (
<>
<div
ref={letterRef}
className="letter"
onClick={() => onClick(name)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<span>{getFirstChar(name)}</span>
</div>
{showTooltip &&
createPortal(
<Tooltip
style={{
top: tooltipPosition.top,
right: tooltipPosition.right,
transform: "translateY(-50%)",
}}
>
{name}
</Tooltip>,
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<ProxyChainItem[]>([]);
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<VirtuosoHandle>(null);
const scrollPositionRef = useRef<Record<string, number>>({});
const [showScrollTop, setShowScrollTop] = useState(false);
const scrollerRef = useRef<Element | null>(null);
const letterContainerRef = useRef<HTMLDivElement>(null);
const alphabetSelectorRef = useRef<HTMLDivElement>(null);
const [maxHeight, setMaxHeight] = useState("auto");
// 使用useMemo缓存字母索引数据
const { groupFirstLetters, letterIndexMap } = useMemo(() => {
const letters = new Set<string>();
const indexMap: Record<string, number> = {};
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 <BaseEmpty text={t("clash_mode_direct")} />;
}
if (isChainMode) {
return (
<>
<Box sx={{ display: "flex", height: "100%", gap: 2 }}>
<Box sx={{ flex: 1, position: "relative" }}>
<Virtuoso
ref={virtuosoRef}
style={{ height: "calc(100% - 14px)" }}
totalCount={renderList.length}
increaseViewportBy={{ top: 200, bottom: 200 }}
overscan={150}
defaultItemHeight={56}
scrollerRef={(ref) => {
scrollerRef.current = ref as Element;
}}
components={{
Footer: () => <div style={{ height: "8px" }} />,
}}
initialScrollTop={scrollPositionRef.current[mode]}
computeItemKey={(index) => renderList[index].key}
itemContent={(index) => (
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === "rule" || mode === "script"}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
isChainMode={isChainMode}
/>
)}
/>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</Box>
<Box sx={{ width: "400px", minWidth: "300px" }}>
<ProxyChain
proxyChain={proxyChain}
onUpdateChain={setProxyChain}
chainConfigData={chainConfigData}
/>
</Box>
</Box>
<Snackbar
open={duplicateWarning.open}
autoHideDuration={3000}
onClose={handleCloseDuplicateWarning}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
>
<Alert
onClose={handleCloseDuplicateWarning}
severity="warning"
variant="filled"
>
{duplicateWarning.message}
</Alert>
</Snackbar>
</>
);
}
return (
<div
style={{ position: "relative", height: "100%", willChange: "transform" }}
@@ -518,22 +351,6 @@ export const ProxyGroups = (props: Props) => {
)}
/>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
<AlphabetSelector ref={alphabetSelectorRef} style={{ maxHeight }}>
<div className="scroll-container">
<div ref={letterContainerRef} className="letter-container">
{groupFirstLetters.map((name) => (
<LetterItem
key={name}
name={name}
onClick={handleLetterClick}
getFirstChar={getFirstChar}
enableAutoScroll={enableAutoScroll}
/>
))}
</div>
</div>
</AlphabetSelector>
</div>
);
};

View File

@@ -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<HeadState>) => 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;

View File

@@ -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 = <T = any>(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,

View File

@@ -31,6 +31,7 @@
"rule": "قاعدة",
"global": "عالمي",
"direct": "مباشر",
"Chain Proxy": "🔗 بروكسي السلسلة",
"script": "سكريبت",
"locate": "الموقع",
"Delay check": "فحص التأخير",

View File

@@ -32,6 +32,7 @@
"rule": "Regel",
"global": "Global",
"direct": "Direktverbindung",
"Chain Proxy": "🔗 Ketten-Proxy",
"script": "Skript",
"locate": "Aktueller Knoten",
"Delay check": "Latenztest",

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -31,6 +31,7 @@
"rule": "قانون",
"global": "جهانی",
"direct": "مستقیم",
"Chain Proxy": "🔗 پراکسی زنجیره‌ای",
"script": "اسکریپت",
"locate": "موقعیت",
"Delay check": "بررسی تأخیر",

View File

@@ -63,6 +63,7 @@
"rule": "aturan",
"global": "global",
"direct": "langsung",
"Chain Proxy": "🔗 Proxy Rantai",
"script": "skrip",
"locate": "Lokasi",
"Delay check": "Periksa Keterlambatan",

View File

@@ -32,6 +32,7 @@
"rule": "ルール",
"global": "グローバル",
"direct": "直接接続",
"Chain Proxy": "🔗 チェーンプロキシ",
"script": "スクリプト",
"locate": "現在のノード",
"Delay check": "遅延テスト",

View File

@@ -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": "모든 제공자 포함",

View File

@@ -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": "Проверка задержки",

View File

@@ -33,6 +33,7 @@
"rule": "kural",
"global": "küresel",
"direct": "doğrudan",
"Chain Proxy": "🔗 Zincir Proxy",
"script": "betik",
"locate": "konum",
"Delay check": "Gecikme kontrolü",

View File

@@ -31,6 +31,7 @@
"rule": "кагыйдә",
"global": "глобаль",
"direct": "туры",
"Chain Proxy": "🔗 Чылбыр прокси",
"script": "скриптлы",
"locate": "Урын",
"Delay check": "Задержканы тикшерү",

View File

@@ -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": "该节点已在链式代理表中"
}

View File

@@ -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": "默認排序",

View File

@@ -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<string | null>(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 = () => {
<BasePage
full
contentStyle={{ height: "101.5%" }}
title={t("Proxy Groups")}
title={isChainMode ? t("Node Pool") : t("Proxy Groups")}
header={
<Box display="flex" alignItems="center" gap={1}>
<ProviderButton />
@@ -66,10 +122,23 @@ const ProxyPage = () => {
</Button>
))}
</ButtonGroup>
<Button
size="small"
variant={isChainMode ? "contained" : "outlined"}
onClick={onToggleChainMode}
sx={{ ml: 1 }}
>
{t("Chain Proxy")}
</Button>
</Box>
}
>
<ProxyGroups mode={curMode!} />
<ProxyGroups
mode={curMode!}
isChainMode={isChainMode}
chainConfigData={chainConfigData}
/>
</BasePage>
);
};

View File

@@ -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<ChainProxyContextType | null>(null);
export const ChainProxyProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [isChainMode, setIsChainMode] = useState(false);
const [chainConfigData, setChainConfigData] = useState<string | null>(null);
const setChainMode = useCallback((isChain: boolean) => {
setIsChainMode(isChain);
}, []);
const setChainConfigDataCallback = useCallback((data: string | null) => {
setChainConfigData(data);
}, []);
return (
<ChainProxyContext.Provider
value={{
isChainMode,
setChainMode,
chainConfigData,
setChainConfigData: setChainConfigDataCallback,
}}
>
{children}
</ChainProxyContext.Provider>
);
};
export const useChainProxy = () => {
const context = useContext(ChainProxyContext);
if (!context) {
throw new Error("useChainProxy must be used within a ChainProxyProvider");
}
return context;
};

View File

@@ -86,6 +86,16 @@ export async function getRuntimeLogs() {
return invoke<Record<string, [string, string][]>>("get_runtime_logs");
}
export async function getRuntimeProxyChainConfig() {
return invoke<string>("get_runtime_proxy_chain_config");
}
export async function updateProxyChainConfigInRuntime(proxyChainConfig: any) {
return invoke<void>("update_proxy_chain_config_in_runtime", {
proxyChainConfig,
});
}
export async function patchClashConfig(payload: Partial<IConfigData>) {
return invoke<void>("patch_clash_config", { payload });
}