feat: optimize proxy page ui

This commit is contained in:
GyDi 2022-11-20 19:46:16 +08:00
parent 6eafb15cf9
commit a4ce7a4037
No known key found for this signature in database
GPG Key ID: 9C3AD40F1F99880A
11 changed files with 430 additions and 435 deletions

View File

@ -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),
})}
>
<BlurOnRounded sx={{ fontSize: "4em" }} />
<InboxRounded sx={{ fontSize: "4em" }} />
<Typography sx={{ fontSize: "1.25em" }}>{text}</Typography>
{extra}
</Box>

View File

@ -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<any>();
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 (
<>
<ProxyHead
sx={{ px: 3, my: 0.5, button: { mr: 0.5 } }}
groupName={groupName}
headState={headState}
onLocation={onLocation}
onCheckDelay={onCheckAll}
onHeadState={setHeadState}
/>
<Virtuoso
ref={virtuosoRef}
style={{ height: "calc(100% - 40px)" }}
totalCount={sortedProxies.length}
itemContent={(index) => (
<ProxyItem
groupName={groupName}
proxy={sortedProxies[index]}
selected={sortedProxies[index].name === now}
showType={headState.showType}
onClick={onChangeProxy}
sx={{ py: 0, px: 2 }}
/>
)}
/>
</>
);
};
export default ProxyGlobal;

View File

@ -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<any>();
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<any>();
// useEffect(() => {
// if (headState.open) {
// clearTimeout(timerRef.current);
// timerRef.current = setTimeout(() => onLocation(false), 500);
// }
// }, [headState.open, sortedProxies]);
return (
<>
<ListItem
button
dense
onClick={() => setHeadState({ open: !headState.open })}
>
<ListItemText
primary={group.name}
secondary={
<>
<SendRounded color="primary" sx={{ mr: 1, fontSize: 14 }} />
<span>{now}</span>
</>
}
secondaryTypographyProps={{
sx: { display: "flex", alignItems: "center" },
}}
/>
{headState.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
</ListItem>
<Collapse in={headState.open} timeout="auto" unmountOnExit>
<ProxyHead
sx={{ pl: 4, pr: 3, my: 0.5, button: { mr: 0.5 } }}
groupName={group.name}
headState={headState}
onLocation={onLocation}
onCheckDelay={onCheckAll}
onHeadState={setHeadState}
/>
{!sortedProxies.length && (
<Box
sx={{
py: 3,
fontSize: 18,
textAlign: "center",
color: "text.secondary",
}}
>
Empty
</Box>
)}
{sortedProxies.length >= 10 ? (
<Virtuoso
ref={virtuosoRef}
style={{ height: "320px", marginBottom: "4px" }}
totalCount={sortedProxies.length}
itemContent={(index) => (
<ProxyItem
groupName={group.name}
proxy={sortedProxies[index]}
selected={sortedProxies[index].name === now}
showType={headState.showType}
sx={{ py: 0, pl: 4 }}
onClick={onChangeProxy}
/>
)}
/>
) : (
<List
component="div"
disablePadding
sx={{ maxHeight: "320px", overflow: "auto", mb: "4px" }}
>
{sortedProxies.map((proxy) => (
<ProxyItem
key={proxy.name}
groupName={group.name}
proxy={proxy}
selected={proxy.name === now}
showType={headState.showType}
sx={{ py: 0, pl: 4 }}
onClick={onChangeProxy}
/>
))}
</List>
)}
<Divider variant="middle" />
</Collapse>
</>
);
};
export default ProxyGroup;

View File

@ -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<VirtuosoHandle>(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 (
<Virtuoso
ref={virtuosoRef}
style={{ height: "100%" }}
totalCount={renderList.length}
itemContent={(index) => (
<ProxyRenderItem
key={renderList[index].key}
item={renderList[index]}
indent={mode === "rule" || mode === "script"}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
)}
/>
);
};
interface RenderProps {
item: IRenderItem;
indent: boolean;
onLocation: (group: IProxyGroupItem) => void;
onCheckAll: (groupName: string) => void;
onHeadState: (groupName: string, patch: Partial<HeadState>) => 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 (
<ListItem
button
dense
onClick={() => onHeadState(group.name, { open: !headState?.open })}
>
<ListItemText
primary={group.name}
secondary={
<>
<SendRounded color="primary" sx={{ mr: 1, fontSize: 14 }} />
{/* <span>{group.type}</span> */}
<span>{group.now}</span>
</>
}
secondaryTypographyProps={{
sx: { display: "flex", alignItems: "center" },
}}
/>
{headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
</ListItem>
);
}
if (type === 1) {
return (
<ProxyHead
sx={{ pl: indent ? 4.5 : 2.5, pr: 3, my: 1, button: { mr: 0.5 } }}
groupName={group.name}
headState={headState!}
onLocation={() => onLocation(group)}
onCheckDelay={() => onCheckAll(group.name)}
onHeadState={(p) => onHeadState(group.name, p)}
/>
);
}
if (type === 2) {
return (
<ProxyItem
groupName={group.name}
proxy={proxy!}
selected={group.now === proxy?.name}
showType={headState?.showType}
sx={{ py: 0, pl: indent ? 4 : 2 }}
onClick={() => onChangeProxy(group, proxy!)}
/>
);
}
if (type === 3) {
return (
<Box
sx={{
py: 2,
pl: indent ? 4.5 : 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<InboxRounded sx={{ fontSize: "2.5em", color: "inherit" }} />
<Typography sx={{ color: "inherit" }}>No Proxies</Typography>
</Box>
);
}
return null;
}

View File

@ -28,7 +28,7 @@ interface Props {
onHeadState: (val: Partial<HeadState>) => 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) => {
</Box>
);
};
export default ProxyHead;

View File

@ -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) => {
</ListItem>
);
};
export default ProxyItem;

View File

@ -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;
}
/**
* /
*/

View File

@ -15,7 +15,7 @@ export interface HeadState {
type HeadStateStorage = Record<string, Record<string, HeadState>>;
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<Record<string, HeadState>>({});
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<HeadState>) => {
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;
}

View File

@ -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,
};
};

29
src/hooks/use-profiles.ts Normal file
View File

@ -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<IProfilesConfig>) => {
await patchProfilesConfig(value);
mutate();
};
const patchCurrent = async (value: Partial<IProfileItem>) => {
if (profiles?.current) {
await patchProfile(profiles.current, value);
mutate();
}
};
return {
profiles,
current: profiles?.items?.find((p) => p.uid === profiles.current),
patchProfiles,
patchCurrent,
};
};

View File

@ -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 (
<BasePage
contentStyle={pageStyle}
contentStyle={{ height: "100%" }}
title={t("Proxy Groups")}
header={
<ButtonGroup size="small">
@ -73,16 +43,16 @@ const ProxyPage = () => {
</ButtonGroup>
}
>
<Paper sx={{ borderRadius: 1, boxShadow: 2, ...paperStyle }}>
{displayGroups.length > 0 ? (
<List>
{displayGroups.map((group) => (
<ProxyGroup key={group.name} group={group} />
))}
</List>
) : (
<BaseEmpty />
)}
<Paper
sx={{
borderRadius: 1,
boxShadow: 2,
height: "100%",
boxSizing: "border-box",
py: 1,
}}
>
<ProxyGroups mode={curMode!} />
</Paper>
</BasePage>
);