diff --git a/src/components/base/base-empty.tsx b/src/components/base/base-empty.tsx
index dc3f132..420aa2f 100644
--- a/src/components/base/base-empty.tsx
+++ b/src/components/base/base-empty.tsx
@@ -1,5 +1,5 @@
import { alpha, Box, Typography } from "@mui/material";
-import { BlurOnRounded } from "@mui/icons-material";
+import { InboxRounded } from "@mui/icons-material";
interface Props {
text?: React.ReactNode;
@@ -21,7 +21,7 @@ const BaseEmpty = (props: Props) => {
color: alpha(palette.text.secondary, 0.75),
})}
>
-
+
{text}
{extra}
diff --git a/src/components/proxy/proxy-global.tsx b/src/components/proxy/proxy-global.tsx
deleted file mode 100644
index 58651d5..0000000
--- a/src/components/proxy/proxy-global.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import useSWR, { useSWRConfig } from "swr";
-import { useEffect, useRef, useState } from "react";
-import { useLockFn } from "ahooks";
-import { Virtuoso } from "react-virtuoso";
-import { providerHealthCheck, updateProxy } from "@/services/api";
-import { getProfiles, patchProfile } from "@/services/cmds";
-import delayManager from "@/services/delay";
-import useHeadState from "./use-head-state";
-import useFilterSort from "./use-filter-sort";
-import ProxyHead from "./proxy-head";
-import ProxyItem from "./proxy-item";
-
-interface Props {
- groupName: string;
- curProxy?: string;
- proxies: IProxyItem[];
-}
-
-// this component will be used for DIRECT/GLOBAL
-const ProxyGlobal = (props: Props) => {
- const { groupName, curProxy, proxies } = props;
-
- const { mutate } = useSWRConfig();
- const [now, setNow] = useState(curProxy || "DIRECT");
-
- const [headState, setHeadState] = useHeadState(groupName);
-
- const virtuosoRef = useRef();
- const sortedProxies = useFilterSort(
- proxies,
- groupName,
- headState.filterText,
- headState.sortType
- );
-
- const { data: profiles } = useSWR("getProfiles", getProfiles);
-
- const onChangeProxy = useLockFn(async (name: string) => {
- await updateProxy(groupName, name);
- setNow(name);
-
- if (groupName === "DIRECT") return;
-
- // update global selected
- const profile = profiles?.items?.find((p) => p.uid === profiles.current);
- if (!profile) return;
- if (!profile.selected) profile.selected = [];
-
- const index = profile.selected.findIndex((item) => item.name === groupName);
- if (index < 0) {
- profile.selected.unshift({ name: groupName, now: name });
- } else {
- profile.selected[index] = { name: groupName, now: name };
- }
-
- await patchProfile(profiles!.current!, { selected: profile.selected });
- });
-
- const onLocation = (smooth = true) => {
- const index = sortedProxies.findIndex((p) => p.name === now);
-
- if (index >= 0) {
- virtuosoRef.current?.scrollToIndex?.({
- index,
- align: "center",
- behavior: smooth ? "smooth" : "auto",
- });
- }
- };
-
- const onCheckAll = useLockFn(async () => {
- const providers = new Set(
- sortedProxies.map((p) => p.provider!).filter(Boolean)
- );
-
- if (providers.size) {
- Promise.allSettled(
- [...providers].map((p) => providerHealthCheck(p))
- ).then(() => mutate("getProxies"));
- }
-
- await delayManager.checkListDelay(
- sortedProxies.filter((p) => !p.provider).map((p) => p.name),
- groupName,
- 16
- );
-
- mutate("getProxies");
- });
-
- useEffect(() => onLocation(false), [groupName]);
-
- useEffect(() => {
- if (groupName === "DIRECT") setNow("DIRECT");
- else if (groupName === "GLOBAL") {
- if (profiles) {
- const current = profiles.current;
- const profile = profiles.items?.find((p) => p.uid === current);
-
- profile?.selected?.forEach((item) => {
- if (item.name === "GLOBAL") {
- if (item.now && item.now !== curProxy) {
- updateProxy("GLOBAL", item.now).then(() => setNow(item!.now!));
- mutate("getProxies");
- }
- }
- });
- }
-
- setNow(curProxy || "DIRECT");
- }
- }, [groupName, curProxy, profiles]);
-
- return (
- <>
-
-
- (
-
- )}
- />
- >
- );
-};
-
-export default ProxyGlobal;
diff --git a/src/components/proxy/proxy-group.tsx b/src/components/proxy/proxy-group.tsx
deleted file mode 100644
index 827359f..0000000
--- a/src/components/proxy/proxy-group.tsx
+++ /dev/null
@@ -1,232 +0,0 @@
-import useSWR, { useSWRConfig } from "swr";
-import { useEffect, useRef, useState } from "react";
-import { useLockFn } from "ahooks";
-import { Virtuoso } from "react-virtuoso";
-import {
- Box,
- Collapse,
- Divider,
- List,
- ListItem,
- ListItemText,
-} from "@mui/material";
-import {
- SendRounded,
- ExpandLessRounded,
- ExpandMoreRounded,
-} from "@mui/icons-material";
-import {
- getConnections,
- providerHealthCheck,
- updateProxy,
- deleteConnection,
-} from "@/services/api";
-import { getProfiles, patchProfile } from "@/services/cmds";
-import { useVergeConfig } from "@/hooks/use-verge-config";
-import delayManager from "@/services/delay";
-import useHeadState from "./use-head-state";
-import useFilterSort from "./use-filter-sort";
-import ProxyHead from "./proxy-head";
-import ProxyItem from "./proxy-item";
-
-interface Props {
- group: IProxyGroupItem;
-}
-
-const ProxyGroup = ({ group }: Props) => {
- const { mutate } = useSWRConfig();
- const [now, setNow] = useState(group.now);
-
- const [headState, setHeadState] = useHeadState(group.name);
-
- const virtuosoRef = useRef();
- const sortedProxies = useFilterSort(
- group.all,
- group.name,
- headState.filterText,
- headState.sortType
- );
-
- const { data: profiles } = useSWR("getProfiles", getProfiles);
- const { data: vergeConfig } = useVergeConfig();
-
- const onChangeProxy = useLockFn(async (name: string) => {
- // Todo: support another proxy group type
- if (group.type !== "Selector") return;
-
- const oldValue = now;
- try {
- setNow(name);
- await updateProxy(group.name, name);
-
- if (vergeConfig?.auto_close_connection) {
- getConnections().then((snapshot) => {
- snapshot.connections.forEach((conn) => {
- if (conn.chains.includes(oldValue!)) {
- deleteConnection(conn.id);
- }
- });
- });
- }
- } catch {
- setNow(oldValue);
- return; // do not update profile
- }
-
- try {
- const profile = profiles?.items?.find((p) => p.uid === profiles.current);
- if (!profile) return;
- if (!profile.selected) profile.selected = [];
-
- const index = profile.selected.findIndex(
- (item) => item.name === group.name
- );
-
- if (index < 0) {
- profile.selected.push({ name: group.name, now: name });
- } else {
- profile.selected[index] = { name: group.name, now: name };
- }
- await patchProfile(profiles!.current!, { selected: profile.selected });
- } catch (err) {
- console.error(err);
- }
- });
-
- const onLocation = (smooth = true) => {
- const index = sortedProxies.findIndex((p) => p.name === now);
-
- if (index >= 0) {
- virtuosoRef.current?.scrollToIndex?.({
- index,
- align: "center",
- behavior: smooth ? "smooth" : "auto",
- });
- }
- };
-
- const onCheckAll = useLockFn(async () => {
- const providers = new Set(
- sortedProxies.map((p) => p.provider!).filter(Boolean)
- );
-
- if (providers.size) {
- Promise.allSettled(
- [...providers].map((p) => providerHealthCheck(p))
- ).then(() => mutate("getProxies"));
- }
-
- await delayManager.checkListDelay(
- sortedProxies.filter((p) => !p.provider).map((p) => p.name),
- group.name,
- 16
- );
-
- mutate("getProxies");
- });
-
- // auto scroll to current index
- useEffect(() => {
- if (headState.open) {
- setTimeout(() => onLocation(false), 10);
- }
- }, [headState.open, sortedProxies]);
-
- // // auto scroll when sorted changed
- // const timerRef = useRef();
- // useEffect(() => {
- // if (headState.open) {
- // clearTimeout(timerRef.current);
- // timerRef.current = setTimeout(() => onLocation(false), 500);
- // }
- // }, [headState.open, sortedProxies]);
-
- return (
- <>
- setHeadState({ open: !headState.open })}
- >
-
-
- {now}
- >
- }
- secondaryTypographyProps={{
- sx: { display: "flex", alignItems: "center" },
- }}
- />
-
- {headState.open ? : }
-
-
-
-
-
- {!sortedProxies.length && (
-
- Empty
-
- )}
-
- {sortedProxies.length >= 10 ? (
- (
-
- )}
- />
- ) : (
-
- {sortedProxies.map((proxy) => (
-
- ))}
-
- )}
-
-
-
- >
- );
-};
-
-export default ProxyGroup;
diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx
new file mode 100644
index 0000000..ebaccc8
--- /dev/null
+++ b/src/components/proxy/proxy-groups.tsx
@@ -0,0 +1,225 @@
+import { useRef } from "react";
+import { useLockFn } from "ahooks";
+import {
+ Box,
+ Divider,
+ ListItem,
+ ListItemText,
+ Typography,
+} from "@mui/material";
+import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
+import {
+ ExpandLessRounded,
+ ExpandMoreRounded,
+ InboxRounded,
+ SendRounded,
+} from "@mui/icons-material";
+import {
+ getConnections,
+ providerHealthCheck,
+ updateProxy,
+ deleteConnection,
+} from "@/services/api";
+import { useProfiles } from "@/hooks/use-profiles";
+import { useVergeConfig } from "@/hooks/use-verge-config";
+import { useRenderList, type IRenderItem } from "./use-render-list";
+import { HeadState } from "./use-head-state";
+import { ProxyHead } from "./proxy-head";
+import { ProxyItem } from "./proxy-item";
+import delayManager from "@/services/delay";
+
+interface Props {
+ mode: string;
+}
+
+export const ProxyGroups = (props: Props) => {
+ const { mode } = props;
+
+ const { renderList, onProxies, onHeadState } = useRenderList(mode);
+
+ const { data: vergeConfig } = useVergeConfig();
+ const { current, patchCurrent } = useProfiles();
+
+ const virtuosoRef = useRef(null);
+
+ // 切换分组的节点代理
+ const handleChangeProxy = useLockFn(
+ async (group: IProxyGroupItem, proxy: IProxyItem) => {
+ if (group.type !== "Selector") return;
+
+ const { name, now } = group;
+ await updateProxy(name, proxy.name);
+ onProxies();
+
+ // 断开连接
+ if (vergeConfig?.auto_close_connection) {
+ getConnections().then(({ connections }) => {
+ connections.forEach((conn) => {
+ if (conn.chains.includes(now!)) {
+ deleteConnection(conn.id);
+ }
+ });
+ });
+ }
+
+ // 保存到selected中
+ if (!current) return;
+ if (!current.selected) current.selected = [];
+
+ const index = current.selected.findIndex(
+ (item) => item.name === group.name
+ );
+
+ if (index < 0) {
+ current.selected.push({ name, now: proxy.name });
+ } else {
+ current.selected[index] = { name, now: proxy.name };
+ }
+ await patchCurrent({ selected: current.selected });
+ }
+ );
+
+ // 测全部延迟
+ const handleCheckAll = useLockFn(async (groupName: string) => {
+ const proxies = renderList
+ .filter((e) => e.type === 2 && e.group?.name === groupName)
+ .map((e) => e.proxy!)
+ .filter(Boolean);
+
+ const providers = new Set(proxies.map((p) => p!.provider!).filter(Boolean));
+
+ if (providers.size) {
+ Promise.allSettled(
+ [...providers].map((p) => providerHealthCheck(p))
+ ).then(() => onProxies());
+ }
+
+ const names = proxies.filter((p) => !p!.provider).map((p) => p!.name);
+ await delayManager.checkListDelay(names, groupName, 24);
+
+ onProxies();
+ });
+
+ // 滚到对应的节点
+ const handleLocation = (group: IProxyGroupItem) => {
+ if (!group) return;
+ const { name, now } = group;
+
+ const index = renderList.findIndex(
+ (e) => e.type === 2 && e.group?.name === name && e.proxy?.name === now
+ );
+
+ if (index >= 0) {
+ virtuosoRef.current?.scrollToIndex?.({
+ index,
+ align: "center",
+ behavior: "smooth",
+ });
+ }
+ };
+
+ return (
+ (
+
+ )}
+ />
+ );
+};
+
+interface RenderProps {
+ item: IRenderItem;
+ indent: boolean;
+ onLocation: (group: IProxyGroupItem) => void;
+ onCheckAll: (groupName: string) => void;
+ onHeadState: (groupName: string, patch: Partial) => void;
+ onChangeProxy: (group: IProxyGroupItem, proxy: IProxyItem) => void;
+}
+
+function ProxyRenderItem(props: RenderProps) {
+ const { indent, item, onLocation, onCheckAll, onHeadState, onChangeProxy } =
+ props;
+ const { type, group, headState, proxy } = item;
+
+ if (type === 0) {
+ return (
+ onHeadState(group.name, { open: !headState?.open })}
+ >
+
+
+ {/* {group.type} */}
+ {group.now}
+ >
+ }
+ secondaryTypographyProps={{
+ sx: { display: "flex", alignItems: "center" },
+ }}
+ />
+ {headState?.open ? : }
+
+ );
+ }
+
+ if (type === 1) {
+ return (
+ onLocation(group)}
+ onCheckDelay={() => onCheckAll(group.name)}
+ onHeadState={(p) => onHeadState(group.name, p)}
+ />
+ );
+ }
+
+ if (type === 2) {
+ return (
+ onChangeProxy(group, proxy!)}
+ />
+ );
+ }
+
+ if (type === 3) {
+ return (
+
+
+ No Proxies
+
+ );
+ }
+
+ return null;
+}
diff --git a/src/components/proxy/proxy-head.tsx b/src/components/proxy/proxy-head.tsx
index 7dbaae0..04bc6ca 100644
--- a/src/components/proxy/proxy-head.tsx
+++ b/src/components/proxy/proxy-head.tsx
@@ -28,7 +28,7 @@ interface Props {
onHeadState: (val: Partial) => void;
}
-const ProxyHead = (props: Props) => {
+export const ProxyHead = (props: Props) => {
const { sx = {}, groupName, headState, onHeadState } = props;
const { showType, sortType, filterText, textState, testUrl } = headState;
@@ -163,5 +163,3 @@ const ProxyHead = (props: Props) => {
);
};
-
-export default ProxyHead;
diff --git a/src/components/proxy/proxy-item.tsx b/src/components/proxy/proxy-item.tsx
index 5dbdb31..d720682 100644
--- a/src/components/proxy/proxy-item.tsx
+++ b/src/components/proxy/proxy-item.tsx
@@ -42,7 +42,7 @@ const TypeBox = styled(Box)(({ theme }) => ({
lineHeight: 1.25,
}));
-const ProxyItem = (props: Props) => {
+export const ProxyItem = (props: Props) => {
const { groupName, proxy, selected, showType = true, sx, onClick } = props;
// -1/<=0 为 不显示
@@ -174,5 +174,3 @@ const ProxyItem = (props: Props) => {
);
};
-
-export default ProxyItem;
diff --git a/src/components/proxy/use-filter-sort.ts b/src/components/proxy/use-filter-sort.ts
index c5c3cba..3d7b7fd 100644
--- a/src/components/proxy/use-filter-sort.ts
+++ b/src/components/proxy/use-filter-sort.ts
@@ -36,6 +36,17 @@ export default function useFilterSort(
}, [proxies, groupName, filterText, sortType, refresh]);
}
+export function filterSort(
+ proxies: IProxyItem[],
+ groupName: string,
+ filterText: string,
+ sortType: ProxySortType
+) {
+ const fp = filterProxies(proxies, groupName, filterText);
+ const sp = sortProxies(fp, groupName, sortType);
+ return sp;
+}
+
/**
* 可以通过延迟数/节点类型 过滤
*/
diff --git a/src/components/proxy/use-head-state.ts b/src/components/proxy/use-head-state.ts
index 5e2a677..630f594 100644
--- a/src/components/proxy/use-head-state.ts
+++ b/src/components/proxy/use-head-state.ts
@@ -15,7 +15,7 @@ export interface HeadState {
type HeadStateStorage = Record>;
const HEAD_STATE_KEY = "proxy-head-state";
-const DEFAULT_STATE: HeadState = {
+export const DEFAULT_STATE: HeadState = {
open: false,
showType: false,
sortType: 0,
@@ -78,3 +78,59 @@ export default function useHeadState(groupName: string) {
return [state, setHeadState] as const;
}
+
+export function useHeadStateNew() {
+ const current = useRecoilValue(atomCurrentProfile);
+
+ const [state, setState] = useState>({});
+
+ useEffect(() => {
+ if (!current) {
+ setState({});
+ return;
+ }
+
+ try {
+ const data = JSON.parse(
+ localStorage.getItem(HEAD_STATE_KEY)!
+ ) as HeadStateStorage;
+
+ const value = data[current] || {};
+
+ if (value && typeof value === "object") {
+ setState(value);
+ } else {
+ setState({});
+ }
+ } catch {}
+ }, [current]);
+
+ const setHeadState = useCallback(
+ (groupName: string, obj: Partial) => {
+ setState((old) => {
+ const state = old[groupName] || DEFAULT_STATE;
+ const ret = { ...old, [groupName]: { ...state, ...obj } };
+
+ // 保存到存储中
+ setTimeout(() => {
+ try {
+ const item = localStorage.getItem(HEAD_STATE_KEY);
+
+ let data = (item ? JSON.parse(item) : {}) as HeadStateStorage;
+
+ if (!data || typeof data !== "object") data = {};
+
+ data[current] = ret;
+
+ localStorage.setItem(HEAD_STATE_KEY, JSON.stringify(data));
+ } catch {}
+ });
+
+ return ret;
+ });
+ },
+ [current]
+ );
+
+ return [state, setHeadState] as const;
+}
diff --git a/src/components/proxy/use-render-list.ts b/src/components/proxy/use-render-list.ts
new file mode 100644
index 0000000..843dc06
--- /dev/null
+++ b/src/components/proxy/use-render-list.ts
@@ -0,0 +1,84 @@
+import useSWR from "swr";
+import { getProxies } from "@/services/api";
+import { useEffect, useMemo } from "react";
+import { filterSort } from "./use-filter-sort";
+import { useHeadStateNew, type HeadState } from "./use-head-state";
+
+export interface IRenderItem {
+ type: 0 | 1 | 2 | 3; // 组 | head | item | empty
+ key: string;
+ group: IProxyGroupItem;
+ proxy?: IProxyItem;
+ headState?: HeadState;
+}
+
+export const useRenderList = (mode: string) => {
+ const { data: proxiesData, mutate: mutateProxies } = useSWR(
+ "getProxies",
+ getProxies,
+ { refreshInterval: 45000, suspense: true }
+ );
+
+ const [headStates, setHeadState] = useHeadStateNew();
+
+ // make sure that fetch the proxies successfully
+ useEffect(() => {
+ if (!proxiesData) return;
+ const { groups, proxies } = proxiesData;
+
+ if (
+ (mode === "rule" && !groups.length) ||
+ (mode === "global" && proxies.length < 2)
+ ) {
+ setTimeout(() => mutateProxies(), 500);
+ }
+ }, [proxiesData, mode]);
+
+ const renderList: IRenderItem[] = useMemo(() => {
+ const useRule = mode === "rule" || mode === "script";
+ const renderGroups =
+ (useRule ? proxiesData?.groups : [proxiesData?.global!]) || [];
+
+ const retList = renderGroups.flatMap((group) => {
+ const headState = headStates[group.name];
+ const ret: IRenderItem[] = [
+ { type: 0, key: group.name, group, headState },
+ ];
+
+ if (headState?.open) {
+ const proxies = filterSort(
+ group.all,
+ group.name,
+ headState.filterText,
+ headState.sortType
+ );
+
+ ret.push({ type: 1, key: `head${group.name}`, group, headState });
+
+ if (!proxies.length) {
+ ret.push({ type: 3, key: `empty${group.name}`, group, headState });
+ }
+
+ return ret.concat(
+ proxies.map((proxy) => ({
+ type: 2,
+ key: `${group.name}-${proxy!.name}`,
+ group,
+ proxy,
+ headState,
+ }))
+ );
+ }
+ return ret;
+ });
+
+ if (!useRule) return retList.slice(1);
+ return retList;
+ }, [headStates, proxiesData, mode]);
+
+ return {
+ renderList,
+ onProxies: mutateProxies,
+ onHeadState: setHeadState,
+ };
+};
diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts
new file mode 100644
index 0000000..17b2886
--- /dev/null
+++ b/src/hooks/use-profiles.ts
@@ -0,0 +1,29 @@
+import useSWR from "swr";
+import {
+ getProfiles,
+ patchProfile,
+ patchProfilesConfig,
+} from "@/services/cmds";
+
+export const useProfiles = () => {
+ const { data: profiles, mutate } = useSWR("getProfiles", getProfiles);
+
+ const patchProfiles = async (value: Partial) => {
+ await patchProfilesConfig(value);
+ mutate();
+ };
+
+ const patchCurrent = async (value: Partial) => {
+ if (profiles?.current) {
+ await patchProfile(profiles.current, value);
+ mutate();
+ }
+ };
+
+ return {
+ profiles,
+ current: profiles?.items?.find((p) => p.uid === profiles.current),
+ patchProfiles,
+ patchCurrent,
+ };
+};
diff --git a/src/pages/proxies.tsx b/src/pages/proxies.tsx
index ca5bc99..6816bbc 100644
--- a/src/pages/proxies.tsx
+++ b/src/pages/proxies.tsx
@@ -1,62 +1,32 @@
-import useSWR, { useSWRConfig } from "swr";
-import { useEffect, useMemo } from "react";
+import useSWR from "swr";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
-import { Button, ButtonGroup, List, Paper } from "@mui/material";
+import { Button, ButtonGroup, Paper } from "@mui/material";
import { getClashConfig, updateConfigs } from "@/services/api";
import { patchClashConfig } from "@/services/cmds";
-import { getProxies } from "@/services/api";
+import { ProxyGroups } from "@/components/proxy/proxy-groups";
import BasePage from "@/components/base/base-page";
-import BaseEmpty from "@/components/base/base-empty";
-import ProxyGroup from "@/components/proxy/proxy-group";
const ProxyPage = () => {
const { t } = useTranslation();
- const { mutate } = useSWRConfig();
- const { data: proxiesData } = useSWR("getProxies", getProxies, {
- refreshInterval: 45000, // 45s
- });
- const { data: clashConfig } = useSWR("getClashConfig", getClashConfig);
+
+ const { data: clashConfig, mutate: mutateClash } = useSWR(
+ "getClashConfig",
+ getClashConfig
+ );
const modeList = ["rule", "global", "direct", "script"];
const curMode = clashConfig?.mode.toLowerCase();
- const { global, groups = [], proxies = [] } = proxiesData ?? {};
-
- // make sure that fetch the proxies successfully
- useEffect(() => {
- if (
- (curMode === "rule" && !groups.length) ||
- (curMode === "global" && proxies.length < 2)
- ) {
- setTimeout(() => mutate("getProxies"), 500);
- }
- }, [groups, proxies, curMode]);
const onChangeMode = useLockFn(async (mode: string) => {
- // switch rapidly
await updateConfigs({ mode });
await patchClashConfig({ mode });
- mutate("getClashConfig");
+ mutateClash();
});
- // 仅mode为全局和直连的时候展示global分组
- const displayGroups = useMemo(() => {
- if (!global) return groups;
- if (curMode === "global" || curMode === "direct" || groups.length === 0)
- return [global, ...groups];
- return groups;
- }, [global, groups, curMode]);
-
- // difference style
- const showGroup = displayGroups.length > 0;
- const pageStyle = showGroup ? {} : { height: "100%" };
- const paperStyle: any = showGroup
- ? { mb: 0.5 }
- : { py: 1, height: "100%", boxSizing: "border-box" };
-
return (
@@ -73,16 +43,16 @@ const ProxyPage = () => {
}
>
-
- {displayGroups.length > 0 ? (
-
- {displayGroups.map((group) => (
-
- ))}
-
- ) : (
-
- )}
+
+
);