Add Func 链式代理 (#4624)
* 添加链式代理gui和语言支持 在Iruntime中添跟新链式代理配置方法 同时添加了cmd * 修复读取运行时代理链配置文件bug * t * 完成链式代理配置构造 * 修复获取链式代理运行时配置的bug * 完整的链式代理功能
This commit is contained in:
@@ -76,6 +76,7 @@ pub async fn sync_tray_proxy_selection() -> CmdResult<()> {
|
|||||||
pub async fn update_proxy_and_sync(group: String, proxy: String) -> CmdResult<()> {
|
pub async fn update_proxy_and_sync(group: String, proxy: String) -> CmdResult<()> {
|
||||||
match IpcManager::global().update_proxy(&group, &proxy).await {
|
match IpcManager::global().update_proxy(&group, &proxy).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
|
// println!("Proxy updated successfully: {} -> {}", group,proxy);
|
||||||
logging!(
|
logging!(
|
||||||
info,
|
info,
|
||||||
Type::Cmd,
|
Type::Cmd,
|
||||||
@@ -107,6 +108,7 @@ pub async fn update_proxy_and_sync(group: String, proxy: String) -> CmdResult<()
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
println!("1111111111111111");
|
||||||
logging!(
|
logging!(
|
||||||
error,
|
error,
|
||||||
Type::Cmd,
|
Type::Cmd,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use super::CmdResult;
|
use super::CmdResult;
|
||||||
use crate::{config::*, wrap_err};
|
use crate::{config::*, core::CoreManager, log_err, wrap_err};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use serde_yaml_ng::Mapping;
|
use serde_yaml_ng::Mapping;
|
||||||
use std::collections::HashMap;
|
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> {
|
pub async fn get_runtime_yaml() -> CmdResult<String> {
|
||||||
let runtime = Config::runtime().await;
|
let runtime = Config::runtime().await;
|
||||||
let runtime = runtime.latest_ref();
|
let runtime = runtime.latest_ref();
|
||||||
|
|
||||||
let config = runtime.config.as_ref();
|
let config = runtime.config.as_ref();
|
||||||
wrap_err!(
|
wrap_err!(
|
||||||
config
|
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)>>> {
|
pub async fn get_runtime_logs() -> CmdResult<HashMap<String, Vec<(String, String)>>> {
|
||||||
Ok(Config::runtime().await.latest_ref().chain_logs.clone())
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,7 +278,6 @@ impl IpcManager {
|
|||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
"name": proxy
|
"name": proxy
|
||||||
});
|
});
|
||||||
|
|
||||||
match self.send_request("PUT", &url, Some(&payload)).await {
|
match self.send_request("PUT", &url, Some(&payload)).await {
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ mod app_init {
|
|||||||
cmd::get_runtime_yaml,
|
cmd::get_runtime_yaml,
|
||||||
cmd::get_runtime_exists,
|
cmd::get_runtime_exists,
|
||||||
cmd::get_runtime_logs,
|
cmd::get_runtime_logs,
|
||||||
|
cmd::get_runtime_proxy_chain_config,
|
||||||
|
cmd::update_proxy_chain_config_in_runtime,
|
||||||
cmd::invoke_uwp_tool,
|
cmd::invoke_uwp_tool,
|
||||||
cmd::copy_clash_env,
|
cmd::copy_clash_env,
|
||||||
cmd::get_proxies,
|
cmd::get_proxies,
|
||||||
|
|||||||
586
src/components/proxy/proxy-chain.tsx
Normal file
586
src/components/proxy/proxy-chain.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,190 +10,37 @@ import { ProxyRender } from "./proxy-render";
|
|||||||
import delayManager from "@/services/delay";
|
import delayManager from "@/services/delay";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollTopButton } from "../layout/scroll-top-button";
|
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 { memo } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { ProxyChain } from "./proxy-chain";
|
||||||
// 将选择器组件抽离出来,避免主组件重渲染时重复创建样式
|
|
||||||
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,
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: string;
|
mode: string;
|
||||||
|
isChainMode?: boolean;
|
||||||
|
chainConfigData?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProxyChainItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
delay?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProxyGroups = (props: Props) => {
|
export const ProxyGroups = (props: Props) => {
|
||||||
const { t } = useTranslation();
|
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();
|
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 timeout = verge?.default_latency_timeout || 10000;
|
||||||
|
|
||||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||||
const scrollPositionRef = useRef<Record<string, number>>({});
|
const scrollPositionRef = useRef<Record<string, number>>({});
|
||||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||||
const scrollerRef = useRef<Element | null>(null);
|
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 恢复滚动位置
|
// 从 localStorage 恢复滚动位置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -323,28 +136,49 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
saveScrollPosition(0);
|
saveScrollPosition(0);
|
||||||
}, [saveScrollPosition]);
|
}, [saveScrollPosition]);
|
||||||
|
|
||||||
// 处理字母点击,使用useCallback
|
// 关闭重复节点警告
|
||||||
const handleLetterClick = useCallback(
|
const handleCloseDuplicateWarning = useCallback(() => {
|
||||||
(name: string) => {
|
setDuplicateWarning({ open: false, message: "" });
|
||||||
const index = letterIndexMap[name];
|
}, []);
|
||||||
if (index !== undefined) {
|
|
||||||
virtuosoRef.current?.scrollToIndex({
|
|
||||||
index,
|
|
||||||
align: "start",
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[letterIndexMap],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChangeProxy = useCallback(
|
const handleChangeProxy = useCallback(
|
||||||
(group: IProxyGroupItem, proxy: IProxyItem) => {
|
(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;
|
if (!["Selector", "URLTest", "Fallback"].includes(group.type)) return;
|
||||||
|
|
||||||
handleProxyGroupChange(group, proxy);
|
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") {
|
if (mode === "direct") {
|
||||||
return <BaseEmpty text={t("clash_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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ position: "relative", height: "100%", willChange: "transform" }}
|
style={{ position: "relative", height: "100%", willChange: "transform" }}
|
||||||
@@ -518,22 +351,6 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
interface RenderProps {
|
interface RenderProps {
|
||||||
item: IRenderItem;
|
item: IRenderItem;
|
||||||
indent: boolean;
|
indent: boolean;
|
||||||
|
isChainMode?: boolean;
|
||||||
onLocation: (group: IRenderItem["group"]) => void;
|
onLocation: (group: IRenderItem["group"]) => void;
|
||||||
onCheckAll: (groupName: string) => void;
|
onCheckAll: (groupName: string) => void;
|
||||||
onHeadState: (groupName: string, patch: Partial<HeadState>) => void;
|
onHeadState: (groupName: string, patch: Partial<HeadState>) => void;
|
||||||
@@ -39,8 +40,15 @@ interface RenderProps {
|
|||||||
|
|
||||||
export const ProxyRender = (props: RenderProps) => {
|
export const ProxyRender = (props: RenderProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { indent, item, onLocation, onCheckAll, onHeadState, onChangeProxy } =
|
const {
|
||||||
props;
|
indent,
|
||||||
|
item,
|
||||||
|
onLocation,
|
||||||
|
onCheckAll,
|
||||||
|
onHeadState,
|
||||||
|
onChangeProxy,
|
||||||
|
isChainMode = false,
|
||||||
|
} = props;
|
||||||
const { type, group, headState, proxy, proxyCol } = item;
|
const { type, group, headState, proxy, proxyCol } = item;
|
||||||
const { verge } = useVerge();
|
const { verge } = useVerge();
|
||||||
const enable_group_icon = verge?.enable_group_icon ?? true;
|
const enable_group_icon = verge?.enable_group_icon ?? true;
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
type HeadState,
|
type HeadState,
|
||||||
} from "./use-head-state";
|
} from "./use-head-state";
|
||||||
import { useAppData } from "@/providers/app-data-provider";
|
import { useAppData } from "@/providers/app-data-provider";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { getRuntimeConfig } from "@/services/cmds";
|
||||||
|
import delayManager from "@/services/delay";
|
||||||
|
|
||||||
// 定义代理项接口
|
// 定义代理项接口
|
||||||
interface IProxyItem {
|
interface IProxyItem {
|
||||||
@@ -88,13 +91,23 @@ const groupProxies = <T = any>(list: T[], size: number): T[][] => {
|
|||||||
}, [] as T[][]);
|
}, [] as T[][]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRenderList = (mode: string) => {
|
export const useRenderList = (mode: string, isChainMode?: boolean) => {
|
||||||
// 使用全局数据提供者
|
// 使用全局数据提供者
|
||||||
const { proxies: proxiesData, refreshProxy } = useAppData();
|
const { proxies: proxiesData, refreshProxy } = useAppData();
|
||||||
const { verge } = useVerge();
|
const { verge } = useVerge();
|
||||||
const { width } = useWindowWidth();
|
const { width } = useWindowWidth();
|
||||||
const [headStates, setHeadState] = useHeadStateNew();
|
const [headStates, setHeadState] = useHeadStateNew();
|
||||||
|
|
||||||
|
// 获取运行时配置用于链式代理模式
|
||||||
|
const { data: runtimeConfig } = useSWR(
|
||||||
|
isChainMode ? "getRuntimeConfig" : null,
|
||||||
|
getRuntimeConfig,
|
||||||
|
{
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateIfStale: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// 计算列数
|
// 计算列数
|
||||||
const col = useMemo(
|
const col = useMemo(
|
||||||
() => calculateColumns(width, verge?.proxy_layout_column || 6),
|
() => calculateColumns(width, verge?.proxy_layout_column || 6),
|
||||||
@@ -115,10 +128,116 @@ export const useRenderList = (mode: string) => {
|
|||||||
}
|
}
|
||||||
}, [proxiesData, mode, refreshProxy]);
|
}, [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(() => {
|
const renderList: IRenderItem[] = useMemo(() => {
|
||||||
if (!proxiesData) return [];
|
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 useRule = mode === "rule" || mode === "script";
|
||||||
const renderGroups =
|
const renderGroups =
|
||||||
useRule && proxiesData.groups.length
|
useRule && proxiesData.groups.length
|
||||||
@@ -190,7 +309,7 @@ export const useRenderList = (mode: string) => {
|
|||||||
|
|
||||||
if (!useRule) return retList.slice(1);
|
if (!useRule) return retList.slice(1);
|
||||||
return retList.filter((item: IRenderItem) => !item.group.hidden);
|
return retList.filter((item: IRenderItem) => !item.group.hidden);
|
||||||
}, [headStates, proxiesData, mode, col]);
|
}, [headStates, proxiesData, mode, col, isChainMode, runtimeConfig]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
renderList,
|
renderList,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"rule": "قاعدة",
|
"rule": "قاعدة",
|
||||||
"global": "عالمي",
|
"global": "عالمي",
|
||||||
"direct": "مباشر",
|
"direct": "مباشر",
|
||||||
|
"Chain Proxy": "🔗 بروكسي السلسلة",
|
||||||
"script": "سكريبت",
|
"script": "سكريبت",
|
||||||
"locate": "الموقع",
|
"locate": "الموقع",
|
||||||
"Delay check": "فحص التأخير",
|
"Delay check": "فحص التأخير",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"rule": "Regel",
|
"rule": "Regel",
|
||||||
"global": "Global",
|
"global": "Global",
|
||||||
"direct": "Direktverbindung",
|
"direct": "Direktverbindung",
|
||||||
|
"Chain Proxy": "🔗 Ketten-Proxy",
|
||||||
"script": "Skript",
|
"script": "Skript",
|
||||||
"locate": "Aktueller Knoten",
|
"locate": "Aktueller Knoten",
|
||||||
"Delay check": "Latenztest",
|
"Delay check": "Latenztest",
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
"Label-Settings": "Settings",
|
"Label-Settings": "Settings",
|
||||||
"Proxies": "Proxies",
|
"Proxies": "Proxies",
|
||||||
"Proxy Groups": "Proxy Groups",
|
"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 Provider": "Proxy Provider",
|
||||||
"Proxy Count": "Proxy Count",
|
"Proxy Count": "Proxy Count",
|
||||||
"Update All": "Update All",
|
"Update All": "Update All",
|
||||||
@@ -33,6 +38,13 @@
|
|||||||
"rule": "rule",
|
"rule": "rule",
|
||||||
"global": "global",
|
"global": "global",
|
||||||
"direct": "direct",
|
"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",
|
"script": "script",
|
||||||
"locate": "locate",
|
"locate": "locate",
|
||||||
"Delay check": "Delay check",
|
"Delay check": "Delay check",
|
||||||
@@ -664,5 +676,6 @@
|
|||||||
"Failed to save configuration": "Failed to save configuration",
|
"Failed to save configuration": "Failed to save configuration",
|
||||||
"Controller address copied to clipboard": "Controller address copied to clipboard",
|
"Controller address copied to clipboard": "Controller address copied to clipboard",
|
||||||
"Secret copied to clipboard": "Secret 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"rule": "Regla",
|
"rule": "Regla",
|
||||||
"global": "Global",
|
"global": "Global",
|
||||||
"direct": "Conexión directa",
|
"direct": "Conexión directa",
|
||||||
|
"Chain Proxy": "🔗 Proxy en cadena",
|
||||||
"script": "Script",
|
"script": "Script",
|
||||||
"locate": "Nodo actual",
|
"locate": "Nodo actual",
|
||||||
"Delay check": "Prueba de latencia",
|
"Delay check": "Prueba de latencia",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"rule": "قانون",
|
"rule": "قانون",
|
||||||
"global": "جهانی",
|
"global": "جهانی",
|
||||||
"direct": "مستقیم",
|
"direct": "مستقیم",
|
||||||
|
"Chain Proxy": "🔗 پراکسی زنجیرهای",
|
||||||
"script": "اسکریپت",
|
"script": "اسکریپت",
|
||||||
"locate": "موقعیت",
|
"locate": "موقعیت",
|
||||||
"Delay check": "بررسی تأخیر",
|
"Delay check": "بررسی تأخیر",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"rule": "aturan",
|
"rule": "aturan",
|
||||||
"global": "global",
|
"global": "global",
|
||||||
"direct": "langsung",
|
"direct": "langsung",
|
||||||
|
"Chain Proxy": "🔗 Proxy Rantai",
|
||||||
"script": "skrip",
|
"script": "skrip",
|
||||||
"locate": "Lokasi",
|
"locate": "Lokasi",
|
||||||
"Delay check": "Periksa Keterlambatan",
|
"Delay check": "Periksa Keterlambatan",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"rule": "ルール",
|
"rule": "ルール",
|
||||||
"global": "グローバル",
|
"global": "グローバル",
|
||||||
"direct": "直接接続",
|
"direct": "直接接続",
|
||||||
|
"Chain Proxy": "🔗 チェーンプロキシ",
|
||||||
"script": "スクリプト",
|
"script": "スクリプト",
|
||||||
"locate": "現在のノード",
|
"locate": "現在のノード",
|
||||||
"Delay check": "遅延テスト",
|
"Delay check": "遅延テスト",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"rule": "규칙",
|
"rule": "규칙",
|
||||||
"global": "전역",
|
"global": "전역",
|
||||||
"direct": "직접",
|
"direct": "직접",
|
||||||
|
"Chain Proxy": "🔗 체인 프록시",
|
||||||
"script": "스크립트",
|
"script": "스크립트",
|
||||||
"locate": "로케이트",
|
"locate": "로케이트",
|
||||||
"Delay check": "지연 확인",
|
"Delay check": "지연 확인",
|
||||||
@@ -126,7 +127,6 @@
|
|||||||
"Lazy": "지연 로딩",
|
"Lazy": "지연 로딩",
|
||||||
"Timeout": "타임아웃",
|
"Timeout": "타임아웃",
|
||||||
"Max Failed Times": "최대 실패 횟수",
|
"Max Failed Times": "최대 실패 횟수",
|
||||||
"Interface Name": "인터페이스 이름",
|
|
||||||
"Routing Mark": "라우팅 마크",
|
"Routing Mark": "라우팅 마크",
|
||||||
"Include All": "모든 프록시 및 제공자 포함",
|
"Include All": "모든 프록시 및 제공자 포함",
|
||||||
"Include All Providers": "모든 제공자 포함",
|
"Include All Providers": "모든 제공자 포함",
|
||||||
|
|||||||
@@ -32,6 +32,13 @@
|
|||||||
"rule": "правила",
|
"rule": "правила",
|
||||||
"global": "глобальный",
|
"global": "глобальный",
|
||||||
"direct": "прямой",
|
"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": "скриптовый",
|
"script": "скриптовый",
|
||||||
"locate": "Местоположение",
|
"locate": "Местоположение",
|
||||||
"Delay check": "Проверка задержки",
|
"Delay check": "Проверка задержки",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"rule": "kural",
|
"rule": "kural",
|
||||||
"global": "küresel",
|
"global": "küresel",
|
||||||
"direct": "doğrudan",
|
"direct": "doğrudan",
|
||||||
|
"Chain Proxy": "🔗 Zincir Proxy",
|
||||||
"script": "betik",
|
"script": "betik",
|
||||||
"locate": "konum",
|
"locate": "konum",
|
||||||
"Delay check": "Gecikme kontrolü",
|
"Delay check": "Gecikme kontrolü",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"rule": "кагыйдә",
|
"rule": "кагыйдә",
|
||||||
"global": "глобаль",
|
"global": "глобаль",
|
||||||
"direct": "туры",
|
"direct": "туры",
|
||||||
|
"Chain Proxy": "🔗 Чылбыр прокси",
|
||||||
"script": "скриптлы",
|
"script": "скриптлы",
|
||||||
"locate": "Урын",
|
"locate": "Урын",
|
||||||
"Delay check": "Задержканы тикшерү",
|
"Delay check": "Задержканы тикшерү",
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
"Label-Settings": "设 置",
|
"Label-Settings": "设 置",
|
||||||
"Proxies": "代理",
|
"Proxies": "代理",
|
||||||
"Proxy Groups": "代理组",
|
"Proxy Groups": "代理组",
|
||||||
|
"Node Pool": "节点池",
|
||||||
|
"Connect": "连接",
|
||||||
|
"Connecting...": "连接中...",
|
||||||
|
"Disconnect": "断开",
|
||||||
|
"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 Config": "代理链配置",
|
||||||
|
"Click nodes in order to add to proxy chain": "顺序点击节点添加到代理链中",
|
||||||
|
"No proxy chain configured": "暂无代理链配置",
|
||||||
|
"Proxy Order": "代理顺序",
|
||||||
|
"timeout": "超时",
|
||||||
|
"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...": "保存中...",
|
||||||
|
"Proxy node already exists in chain": "该节点已在链式代理表中"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,11 @@
|
|||||||
"Label-Unlock": "測 試",
|
"Label-Unlock": "測 試",
|
||||||
"Label-Settings": "設 置",
|
"Label-Settings": "設 置",
|
||||||
"Proxy Groups": "代理組",
|
"Proxy Groups": "代理組",
|
||||||
|
"Node Pool": "節點池",
|
||||||
|
"Connect": "連接",
|
||||||
|
"Connecting...": "連接中...",
|
||||||
|
"Disconnect": "斷開",
|
||||||
|
"Failed to connect to proxy chain": "連接鏈式代理失敗",
|
||||||
"Proxy Provider": "代理集合",
|
"Proxy Provider": "代理集合",
|
||||||
"Proxy Count": "節點數量",
|
"Proxy Count": "節點數量",
|
||||||
"Update All": "更新全部",
|
"Update All": "更新全部",
|
||||||
@@ -33,6 +38,7 @@
|
|||||||
"global": "全局",
|
"global": "全局",
|
||||||
"direct": "直連",
|
"direct": "直連",
|
||||||
"script": "腳本",
|
"script": "腳本",
|
||||||
|
"Chain Proxy": "🔗 鏈式代理",
|
||||||
"locate": "當前節點",
|
"locate": "當前節點",
|
||||||
"Delay check": "延遲測試",
|
"Delay check": "延遲測試",
|
||||||
"Sort by default": "默認排序",
|
"Sort by default": "默認排序",
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLockFn } from "ahooks";
|
import { useLockFn } from "ahooks";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Box, Button, ButtonGroup } from "@mui/material";
|
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 { patchClashMode } from "@/services/cmds";
|
||||||
import { useVerge } from "@/hooks/use-verge";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
import { BasePage } from "@/components/base";
|
import { BasePage } from "@/components/base";
|
||||||
@@ -13,6 +18,18 @@ import { ProviderButton } from "@/components/proxy/provider-button";
|
|||||||
const ProxyPage = () => {
|
const ProxyPage = () => {
|
||||||
const { t } = useTranslation();
|
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(
|
const { data: clashConfig, mutate: mutateClash } = useSWR(
|
||||||
"getClashConfig",
|
"getClashConfig",
|
||||||
getClashConfig,
|
getClashConfig,
|
||||||
@@ -39,6 +56,45 @@ const ProxyPage = () => {
|
|||||||
mutateClash();
|
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(() => {
|
useEffect(() => {
|
||||||
if (curMode && !modeList.includes(curMode)) {
|
if (curMode && !modeList.includes(curMode)) {
|
||||||
onChangeMode("rule");
|
onChangeMode("rule");
|
||||||
@@ -49,7 +105,7 @@ const ProxyPage = () => {
|
|||||||
<BasePage
|
<BasePage
|
||||||
full
|
full
|
||||||
contentStyle={{ height: "101.5%" }}
|
contentStyle={{ height: "101.5%" }}
|
||||||
title={t("Proxy Groups")}
|
title={isChainMode ? t("Node Pool") : t("Proxy Groups")}
|
||||||
header={
|
header={
|
||||||
<Box display="flex" alignItems="center" gap={1}>
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
<ProviderButton />
|
<ProviderButton />
|
||||||
@@ -66,10 +122,23 @@ const ProxyPage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={isChainMode ? "contained" : "outlined"}
|
||||||
|
onClick={onToggleChainMode}
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
>
|
||||||
|
{t("Chain Proxy")}
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ProxyGroups mode={curMode!} />
|
<ProxyGroups
|
||||||
|
mode={curMode!}
|
||||||
|
isChainMode={isChainMode}
|
||||||
|
chainConfigData={chainConfigData}
|
||||||
|
/>
|
||||||
</BasePage>
|
</BasePage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
48
src/providers/chain-proxy-provider.tsx
Normal file
48
src/providers/chain-proxy-provider.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -86,6 +86,16 @@ export async function getRuntimeLogs() {
|
|||||||
return invoke<Record<string, [string, string][]>>("get_runtime_logs");
|
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>) {
|
export async function patchClashConfig(payload: Partial<IConfigData>) {
|
||||||
return invoke<void>("patch_clash_config", { payload });
|
return invoke<void>("patch_clash_config", { payload });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user