398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { cn } from "@root/lib/utils";
|
|
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import {AlertTriangle, ChevronsUpDown, Timer, WholeWord} from "lucide-react";
|
|
|
|
import { useVerge } from "@/hooks/use-verge";
|
|
import { useAppData } from "@/providers/app-data-provider";
|
|
import delayManager from "@/services/delay";
|
|
import { updateProxy, deleteConnection } from "@/services/api";
|
|
|
|
const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
|
|
const STORAGE_KEY_SORT_TYPE = "clash-verge-proxy-sort-type";
|
|
const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"];
|
|
type ProxySortType = "default" | "delay" | "name";
|
|
interface IProxyGroup {
|
|
name: string;
|
|
type: string;
|
|
now: string;
|
|
hidden: boolean;
|
|
all: (string | { name: string })[];
|
|
icon?: string;
|
|
}
|
|
|
|
function getDelayColorClasses(delayValue: number): string {
|
|
if (delayValue < 0) {
|
|
return "text-muted-foreground border-border";
|
|
}
|
|
if (delayValue >= 150) {
|
|
return "text-destructive border-destructive/40";
|
|
}
|
|
return "text-green-600 border-green-500/40 dark:text-green-400 dark:border-green-400/30";
|
|
}
|
|
|
|
const ProxySelectItem = ({
|
|
proxyName,
|
|
groupName,
|
|
}: {
|
|
proxyName: string;
|
|
groupName: string;
|
|
}) => {
|
|
const [delay, setDelay] = useState(() =>
|
|
delayManager.getDelay(proxyName, groupName),
|
|
);
|
|
const [isJustUpdated, setIsJustUpdated] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const listener = (newDelay: number) => {
|
|
setDelay((currentDelay) => {
|
|
if (newDelay >= 0 && newDelay !== currentDelay) {
|
|
setIsJustUpdated(true);
|
|
setTimeout(() => setIsJustUpdated(false), 600);
|
|
}
|
|
return newDelay;
|
|
});
|
|
};
|
|
|
|
delayManager.setListener(proxyName, groupName, listener);
|
|
return () => {
|
|
delayManager.removeListener(proxyName, groupName);
|
|
};
|
|
}, [proxyName, groupName]);
|
|
|
|
return (
|
|
<SelectItem key={proxyName} value={proxyName}>
|
|
<div className="flex items-center justify-between w-full">
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
"mr-2 flex-shrink-0 px-2 h-5 w-8 justify-center transition-colors duration-300",
|
|
getDelayColorClasses(delay),
|
|
isJustUpdated && "bg-primary/10 border-primary/50",
|
|
)}
|
|
>
|
|
{delay < 0 || delay > 10000 ? "---" : delay}
|
|
</Badge>
|
|
<span className="truncate">{proxyName}</span>
|
|
</div>
|
|
</SelectItem>
|
|
);
|
|
};
|
|
|
|
export const ProxySelectors: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const { verge } = useVerge();
|
|
const { proxies, connections, clashConfig, refreshProxy } = useAppData();
|
|
|
|
const mode = clashConfig?.mode?.toLowerCase() || "rule";
|
|
const isGlobalMode = mode === "global";
|
|
const isDirectMode = mode === "direct";
|
|
|
|
const [selectedGroup, setSelectedGroup] = useState<string>("");
|
|
const [selectedProxy, setSelectedProxy] = useState<string>("");
|
|
const [sortType, setSortType] = useState<ProxySortType>(
|
|
() =>
|
|
(localStorage.getItem(STORAGE_KEY_SORT_TYPE) as ProxySortType) ||
|
|
"default",
|
|
);
|
|
const enable_group_icon = verge?.enable_group_icon ?? true;
|
|
|
|
useEffect(() => {
|
|
if (!proxies?.groups) return;
|
|
if (isGlobalMode) {
|
|
setSelectedGroup("GLOBAL");
|
|
return;
|
|
}
|
|
if (isDirectMode) {
|
|
setSelectedGroup("DIRECT");
|
|
return;
|
|
}
|
|
|
|
const savedGroup = localStorage.getItem(STORAGE_KEY_GROUP);
|
|
const primaryGroup =
|
|
proxies.groups.find(
|
|
(g: IProxyGroup) =>
|
|
g.type === "Selector" && g.name.toLowerCase().includes("auto"),
|
|
) || proxies.groups.find((g: IProxyGroup) => g.type === "Selector");
|
|
|
|
if (
|
|
savedGroup &&
|
|
proxies.groups.some((g: IProxyGroup) => g.name === savedGroup)
|
|
) {
|
|
setSelectedGroup(savedGroup);
|
|
} else if (primaryGroup) {
|
|
setSelectedGroup(primaryGroup.name);
|
|
} else if (proxies.groups.length > 0) {
|
|
const firstSelector = proxies.groups.find(
|
|
(g: IProxyGroup) => g.type === "Selector",
|
|
);
|
|
if (firstSelector) {
|
|
setSelectedGroup(firstSelector.name);
|
|
}
|
|
}
|
|
}, [proxies, isGlobalMode, isDirectMode]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedGroup || !proxies) return;
|
|
if (isGlobalMode) {
|
|
setSelectedProxy(proxies.global?.now || "");
|
|
return;
|
|
}
|
|
if (isDirectMode) {
|
|
setSelectedProxy("DIRECT");
|
|
return;
|
|
}
|
|
const group = proxies.groups.find(
|
|
(g: IProxyGroup) => g.name === selectedGroup,
|
|
);
|
|
if (group) {
|
|
const current = group.now;
|
|
const firstInList =
|
|
typeof group.all?.[0] === "string"
|
|
? group.all[0]
|
|
: group.all?.[0]?.name;
|
|
setSelectedProxy(current || firstInList || "");
|
|
}
|
|
}, [selectedGroup, proxies, isGlobalMode, isDirectMode]);
|
|
|
|
const handleProxyListOpen = useCallback(
|
|
(isOpen: boolean) => {
|
|
if (!isOpen || isDirectMode) return;
|
|
|
|
const timeout = verge?.default_latency_timeout || 5000;
|
|
|
|
if (isGlobalMode) {
|
|
const proxyList = proxies?.global?.all;
|
|
if (proxyList) {
|
|
const proxyNames = proxyList
|
|
.map((p: any) => (typeof p === "string" ? p : p.name))
|
|
.filter((name: string) => name && !presetList.includes(name));
|
|
|
|
delayManager.checkListDelay(proxyNames, "GLOBAL", timeout);
|
|
}
|
|
} else {
|
|
const group = proxies?.groups?.find(
|
|
(g: IProxyGroup) => g.name === selectedGroup,
|
|
);
|
|
if (group && group.all) {
|
|
const proxyNames = group.all
|
|
.map((p: any) => (typeof p === "string" ? p : p.name))
|
|
.filter(Boolean);
|
|
delayManager.checkListDelay(proxyNames, selectedGroup, timeout);
|
|
}
|
|
}
|
|
},
|
|
[selectedGroup, proxies, isGlobalMode, isDirectMode, verge],
|
|
);
|
|
|
|
const handleGroupChange = (newGroup: string) => {
|
|
if (isGlobalMode || isDirectMode) return;
|
|
setSelectedGroup(newGroup);
|
|
localStorage.setItem(STORAGE_KEY_GROUP, newGroup);
|
|
};
|
|
|
|
const handleProxyChange = async (newProxy: string) => {
|
|
if (newProxy === selectedProxy) return;
|
|
const previousProxy = selectedProxy;
|
|
setSelectedProxy(newProxy);
|
|
try {
|
|
await updateProxy(selectedGroup, newProxy);
|
|
if (verge?.auto_close_connection && previousProxy) {
|
|
connections?.data.forEach((conn: any) => {
|
|
if (conn.chains.includes(previousProxy)) {
|
|
deleteConnection(conn.id);
|
|
}
|
|
});
|
|
}
|
|
setTimeout(() => refreshProxy(), 300);
|
|
} catch (error) {
|
|
console.error("Failed to update proxy", error);
|
|
}
|
|
};
|
|
|
|
const handleSortChange = () => {
|
|
const nextSort: Record<ProxySortType, ProxySortType> = {
|
|
default: "delay",
|
|
delay: "name",
|
|
name: "default",
|
|
};
|
|
const newSortType = nextSort[sortType];
|
|
setSortType(newSortType);
|
|
localStorage.setItem(STORAGE_KEY_SORT_TYPE, newSortType);
|
|
};
|
|
|
|
const selectorGroups = useMemo(() => {
|
|
if (!proxies?.groups) return [];
|
|
|
|
const allowedTypes = ["Selector", "URLTest", "Fallback"];
|
|
|
|
return proxies.groups.filter(
|
|
(g: IProxyGroup) => allowedTypes.includes(g.type) && !g.hidden,
|
|
);
|
|
}, [proxies]);
|
|
|
|
const proxyOptions = useMemo(() => {
|
|
let options: { name: string }[] = [];
|
|
if (isDirectMode) return [{ name: "DIRECT" }];
|
|
|
|
const sourceList = isGlobalMode
|
|
? proxies?.global?.all
|
|
: proxies?.groups?.find((g: IProxyGroup) => g.name === selectedGroup)
|
|
?.all;
|
|
|
|
if (sourceList) {
|
|
const rawOptions = sourceList
|
|
.map((proxy: any) => ({
|
|
name: typeof proxy === "string" ? proxy : proxy.name,
|
|
}))
|
|
.filter((p: { name: string }) => p.name);
|
|
|
|
const uniqueNames = new Set<string>();
|
|
options = rawOptions.filter((proxy: any) => {
|
|
if (!uniqueNames.has(proxy.name)) {
|
|
uniqueNames.add(proxy.name);
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
if (sortType === "name")
|
|
return options.sort((a, b) => a.name.localeCompare(b.name));
|
|
if (sortType === "delay") {
|
|
return options.sort((a, b) => {
|
|
const delayA = delayManager.getDelay(a.name, selectedGroup);
|
|
const delayB = delayManager.getDelay(b.name, selectedGroup);
|
|
if (delayA < 0) return 1;
|
|
if (delayB < 0) return -1;
|
|
return delayA - delayB;
|
|
});
|
|
}
|
|
return options;
|
|
}, [selectedGroup, proxies, sortType, isGlobalMode, isDirectMode]);
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<div className="flex justify-center flex-col gap-2 md:items-end">
|
|
<div className="flex flex-col items-start gap-2">
|
|
<label className="text-sm font-medium text-muted-foreground">
|
|
{t("Group")}
|
|
</label>
|
|
<Select
|
|
value={selectedGroup}
|
|
onValueChange={handleGroupChange}
|
|
disabled={isGlobalMode || isDirectMode}
|
|
>
|
|
<SelectTrigger className="w-100">
|
|
{isGlobalMode ? (
|
|
<div className="flex items-center gap-2 text-destructive">
|
|
<AlertTriangle className="h-4 w-4 text-destructive" />
|
|
<span className="font-medium text-sm">
|
|
{t("Global Mode Active")}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 truncate">
|
|
<span className="truncate">
|
|
<SelectValue placeholder={t("Select a group...")} />
|
|
</span>
|
|
</div>
|
|
)}
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{selectorGroups.map((group: IProxyGroup) => (
|
|
<SelectItem key={group.name} value={group.name}>
|
|
<div className="flex items-center gap-2">
|
|
{enable_group_icon && group.icon && (
|
|
<img
|
|
src={
|
|
group.icon.startsWith("data")
|
|
? group.icon
|
|
: group.icon.startsWith("<svg")
|
|
? `data:image/svg+xml;base64,${btoa(group.icon)}`
|
|
: group.icon
|
|
}
|
|
className="w-4 h-4 rounded-sm"
|
|
alt={group.name}
|
|
/>
|
|
)}
|
|
<span>{group.name}</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-start gap-2">
|
|
<div className="flex justify-between items-center w-100">
|
|
<label className="text-sm font-medium text-muted-foreground">
|
|
{t("Proxy")}
|
|
</label>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="truncate">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleSortChange}
|
|
disabled={isDirectMode}
|
|
>
|
|
{sortType === "default" && (
|
|
<ChevronsUpDown className="h-4 w-4" />
|
|
)}
|
|
{sortType === "delay" && <Timer className="h-4 w-4" />}
|
|
{sortType === "name" && <WholeWord className="h-4 w-4" />}
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{sortType === "default" && <p>{t("Sort by default")}</p>}
|
|
{sortType === "delay" && <p>{t("Sort by delay")}</p>}
|
|
{sortType === "name" && <p>{t("Sort by name")}</p>}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
<Select
|
|
value={selectedProxy}
|
|
onValueChange={handleProxyChange}
|
|
disabled={isDirectMode}
|
|
onOpenChange={handleProxyListOpen}
|
|
>
|
|
<SelectTrigger className="w-100">
|
|
<span className="truncate">
|
|
<SelectValue placeholder={t("Select a proxy...")} />
|
|
</span>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{proxyOptions.map((proxy) => (
|
|
<ProxySelectItem
|
|
key={proxy.name}
|
|
proxyName={proxy.name}
|
|
groupName={selectedGroup}
|
|
/>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</TooltipProvider>
|
|
);
|
|
};
|