From a4ce7a4037537369ecfca70e696a1b0ed3771156 Mon Sep 17 00:00:00 2001 From: GyDi Date: Sun, 20 Nov 2022 19:46:16 +0800 Subject: [PATCH] feat: optimize proxy page ui --- src/components/base/base-empty.tsx | 4 +- src/components/proxy/proxy-global.tsx | 144 --------------- src/components/proxy/proxy-group.tsx | 232 ------------------------ src/components/proxy/proxy-groups.tsx | 225 +++++++++++++++++++++++ src/components/proxy/proxy-head.tsx | 4 +- src/components/proxy/proxy-item.tsx | 4 +- src/components/proxy/use-filter-sort.ts | 11 ++ src/components/proxy/use-head-state.ts | 58 +++++- src/components/proxy/use-render-list.ts | 84 +++++++++ src/hooks/use-profiles.ts | 29 +++ src/pages/proxies.tsx | 70 ++----- 11 files changed, 430 insertions(+), 435 deletions(-) delete mode 100644 src/components/proxy/proxy-global.tsx delete mode 100644 src/components/proxy/proxy-group.tsx create mode 100644 src/components/proxy/proxy-groups.tsx create mode 100644 src/components/proxy/use-render-list.ts create mode 100644 src/hooks/use-profiles.ts 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) => ( - - ))} - - ) : ( - - )} + + );