mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-01-28 07:14:40 +08:00
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<()> {
|
||||
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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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!({
|
||||
"name": proxy
|
||||
});
|
||||
|
||||
match self.send_request("PUT", &url, Some(&payload)).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
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 { 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"rule": "قاعدة",
|
||||
"global": "عالمي",
|
||||
"direct": "مباشر",
|
||||
"Chain Proxy": "🔗 بروكسي السلسلة",
|
||||
"script": "سكريبت",
|
||||
"locate": "الموقع",
|
||||
"Delay check": "فحص التأخير",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"rule": "Regel",
|
||||
"global": "Global",
|
||||
"direct": "Direktverbindung",
|
||||
"Chain Proxy": "🔗 Ketten-Proxy",
|
||||
"script": "Skript",
|
||||
"locate": "Aktueller Knoten",
|
||||
"Delay check": "Latenztest",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"rule": "قانون",
|
||||
"global": "جهانی",
|
||||
"direct": "مستقیم",
|
||||
"Chain Proxy": "🔗 پراکسی زنجیرهای",
|
||||
"script": "اسکریپت",
|
||||
"locate": "موقعیت",
|
||||
"Delay check": "بررسی تأخیر",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"rule": "aturan",
|
||||
"global": "global",
|
||||
"direct": "langsung",
|
||||
"Chain Proxy": "🔗 Proxy Rantai",
|
||||
"script": "skrip",
|
||||
"locate": "Lokasi",
|
||||
"Delay check": "Periksa Keterlambatan",
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"rule": "ルール",
|
||||
"global": "グローバル",
|
||||
"direct": "直接接続",
|
||||
"Chain Proxy": "🔗 チェーンプロキシ",
|
||||
"script": "スクリプト",
|
||||
"locate": "現在のノード",
|
||||
"Delay check": "遅延テスト",
|
||||
|
||||
@@ -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": "모든 제공자 포함",
|
||||
|
||||
@@ -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": "Проверка задержки",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"rule": "kural",
|
||||
"global": "küresel",
|
||||
"direct": "doğrudan",
|
||||
"Chain Proxy": "🔗 Zincir Proxy",
|
||||
"script": "betik",
|
||||
"locate": "konum",
|
||||
"Delay check": "Gecikme kontrolü",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"rule": "кагыйдә",
|
||||
"global": "глобаль",
|
||||
"direct": "туры",
|
||||
"Chain Proxy": "🔗 Чылбыр прокси",
|
||||
"script": "скриптлы",
|
||||
"locate": "Урын",
|
||||
"Delay check": "Задержканы тикшерү",
|
||||
|
||||
@@ -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": "该节点已在链式代理表中"
|
||||
}
|
||||
|
||||
@@ -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": "默認排序",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user