From b6f4695bcdfbc8bba6a91e9867b2c3d927c48b00 Mon Sep 17 00:00:00 2001 From: MystiPanda Date: Wed, 17 Jan 2024 11:02:17 +0800 Subject: [PATCH] feat: Add Test Page --- src-tauri/src/cmds.rs | 5 + src-tauri/src/config/verge.rs | 12 ++ src-tauri/src/feat.rs | 36 +++++ src-tauri/src/main.rs | 1 + src/components/test/test-box.tsx | 42 ++++++ src/components/test/test-item.tsx | 213 ++++++++++++++++++++++++++++ src/components/test/test-viewer.tsx | 153 ++++++++++++++++++++ src/locales/zh.json | 7 + src/pages/_routers.tsx | 6 + src/pages/test.tsx | 164 +++++++++++++++++++++ src/services/cmds.ts | 4 + src/services/delay.ts | 7 +- src/services/types.d.ts | 8 ++ 13 files changed, 654 insertions(+), 4 deletions(-) create mode 100644 src/components/test/test-box.tsx create mode 100644 src/components/test/test-item.tsx create mode 100644 src/components/test/test-viewer.tsx create mode 100644 src/pages/test.tsx diff --git a/src-tauri/src/cmds.rs b/src-tauri/src/cmds.rs index 569e9d74..aa4017f1 100644 --- a/src-tauri/src/cmds.rs +++ b/src-tauri/src/cmds.rs @@ -261,6 +261,11 @@ pub fn get_portable_flag() -> CmdResult { Ok(*dirs::PORTABLE_FLAG.get().unwrap_or(&false)) } +#[tauri::command] +pub async fn test_delay(url: String) -> CmdResult { + Ok(feat::test_delay(url).await.unwrap_or(10000u32)) +} + #[cfg(windows)] pub mod service { use super::*; diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index e2c45e3a..9582793c 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -88,6 +88,9 @@ pub struct IVerge { /// proxy 页面布局 列数 pub proxy_layout_column: Option, + /// 测试网站列表 + pub test_list: Option>, + /// 日志清理 /// 0: 不清理; 1: 7天; 2: 30天; 3: 90天 pub auto_log_clean: Option, @@ -103,6 +106,14 @@ pub struct IVerge { pub verge_mixed_port: Option, } +#[derive(Default, Debug, Clone, Deserialize, Serialize)] +pub struct IVergeTestItem { + pub uid: Option, + pub name: Option, + pub icon: Option, + pub url: Option, +} + #[derive(Default, Debug, Clone, Deserialize, Serialize)] pub struct IVergeTheme { pub primary_color: Option, @@ -202,6 +213,7 @@ impl IVerge { patch!(default_latency_test); patch!(enable_builtin_enhanced); patch!(proxy_layout_column); + patch!(test_list); patch!(enable_clash_fields); patch!(auto_log_clean); patch!(window_size_position); diff --git a/src-tauri/src/feat.rs b/src-tauri/src/feat.rs index 0e8a9eb9..8dfd31bc 100644 --- a/src-tauri/src/feat.rs +++ b/src-tauri/src/feat.rs @@ -368,3 +368,39 @@ pub fn copy_clash_env(app_handle: &AppHandle) { _ => log::error!(target: "app", "copy_clash_env: Invalid env type! {env_type}"), }; } + +pub async fn test_delay(url: String) -> Result { + use tokio::time::{Duration, Instant}; + let mut builder = reqwest::ClientBuilder::new().use_rustls_tls().no_proxy(); + + let port = Config::verge() + .latest() + .verge_mixed_port + .unwrap_or(Config::clash().data().get_mixed_port()); + + let proxy_scheme = format!("http://127.0.0.1:{port}"); + + if let Ok(proxy) = reqwest::Proxy::http(&proxy_scheme) { + builder = builder.proxy(proxy); + } + if let Ok(proxy) = reqwest::Proxy::https(&proxy_scheme) { + builder = builder.proxy(proxy); + } + if let Ok(proxy) = reqwest::Proxy::all(&proxy_scheme) { + builder = builder.proxy(proxy); + } + + let request = builder + .timeout(Duration::from_millis(10000)) + .build()? + .get(url); + let start = Instant::now(); + + let response = request.send().await?; + if response.status().is_success() { + let delay = start.elapsed().as_millis() as u32; + Ok(delay) + } else { + Ok(10000u32) + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index be57c645..7723393a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -54,6 +54,7 @@ fn main() -> std::io::Result<()> { // verge cmds::get_verge_config, cmds::patch_verge_config, + cmds::test_delay, // cmds::update_hotkeys, // profile cmds::get_profiles, diff --git a/src/components/test/test-box.tsx b/src/components/test/test-box.tsx new file mode 100644 index 00000000..5e161136 --- /dev/null +++ b/src/components/test/test-box.tsx @@ -0,0 +1,42 @@ +import { alpha, Box, styled } from "@mui/material"; + +export const TestBox = styled(Box)(({ theme, "aria-selected": selected }) => { + const { mode, primary, text, grey, background } = theme.palette; + const key = `${mode}-${!!selected}`; + + const backgroundColor = { + "light-true": alpha(primary.main, 0.2), + "light-false": alpha(background.paper, 0.75), + "dark-true": alpha(primary.main, 0.45), + "dark-false": alpha(grey[700], 0.45), + }[key]!; + + const color = { + "light-true": text.secondary, + "light-false": text.secondary, + "dark-true": alpha(text.secondary, 0.85), + "dark-false": alpha(text.secondary, 0.65), + }[key]!; + + const h2color = { + "light-true": primary.main, + "light-false": text.primary, + "dark-true": primary.light, + "dark-false": text.primary, + }[key]!; + + return { + position: "relative", + width: "100%", + display: "block", + cursor: "pointer", + textAlign: "left", + borderRadius: theme.shape.borderRadius, + boxShadow: theme.shadows[2], + padding: "8px 16px", + boxSizing: "border-box", + backgroundColor, + color, + "& h2": { color: h2color }, + }; +}); diff --git a/src/components/test/test-item.tsx b/src/components/test/test-item.tsx new file mode 100644 index 00000000..03a48ef8 --- /dev/null +++ b/src/components/test/test-item.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState } from "react"; +import { useLockFn } from "ahooks"; +import { useTranslation } from "react-i18next"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + Box, + Typography, + Divider, + MenuItem, + Menu, + styled, + alpha, +} from "@mui/material"; +import { BaseLoading } from "@/components/base"; +import { LanguageTwoTone } from "@mui/icons-material"; +import { Notice } from "@/components/base"; +import { TestBox } from "./test-box"; +import delayManager from "@/services/delay"; +import { cmdTestDelay } from "@/services/cmds"; +import { listen, Event, UnlistenFn } from "@tauri-apps/api/event"; + +interface Props { + id: string; + itemData: IVergeTestItem; + onEdit: () => void; + onDelete: (uid: string) => void; +} + +let eventListener: UnlistenFn | null = null; + +export const TestItem = (props: Props) => { + const { itemData, onEdit, onDelete: onDeleteItem } = props; + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: props.id }); + + const { t } = useTranslation(); + const [anchorEl, setAnchorEl] = useState(null); + const [position, setPosition] = useState({ left: 0, top: 0 }); + const [delay, setDelay] = useState(-1); + const { uid, name, icon, url } = itemData; + + const onDelay = async () => { + setDelay(-2); + const result = await cmdTestDelay(url); + setDelay(result); + }; + + const onEditTest = () => { + setAnchorEl(null); + onEdit(); + }; + + const onDelete = useLockFn(async () => { + setAnchorEl(null); + try { + onDeleteItem(uid); + } catch (err: any) { + Notice.error(err?.message || err.toString()); + } + }); + + const menu = [ + { label: "Edit", handler: onEditTest }, + { label: "Delete", handler: onDelete }, + ]; + + const listenTsetEvent = async () => { + if (eventListener !== null) { + eventListener(); + } + eventListener = await listen("verge://test-all", () => { + onDelay(); + }); + }; + + useEffect(() => { + onDelay(); + listenTsetEvent(); + }, []); + + return ( + + { + const { clientX, clientY } = event; + setPosition({ top: clientY, left: clientX }); + setAnchorEl(event.currentTarget); + event.preventDefault(); + }} + > + + {icon ? ( + + {icon?.trim().startsWith("http") ? ( + + ) : ( + + )} + + ) : ( + + + + )} + + + + {name} + + + + + + {delay === -2 && ( + + + + )} + + {delay === -1 && ( + { + e.preventDefault(); + e.stopPropagation(); + onDelay(); + }} + sx={({ palette }) => ({ + ":hover": { bgcolor: alpha(palette.primary.main, 0.15) }, + })} + > + Check + + )} + + {delay >= 0 && ( + // 显示延迟 + { + e.preventDefault(); + e.stopPropagation(); + onDelay(); + }} + color={delayManager.formatDelayColor(delay)} + sx={({ palette }) => ({ + ":hover": { + bgcolor: alpha(palette.primary.main, 0.15), + }, + })} + > + {delayManager.formatDelay(delay)} + + )} + + + + setAnchorEl(null)} + anchorPosition={position} + anchorReference="anchorPosition" + transitionDuration={225} + MenuListProps={{ sx: { py: 0.5 } }} + onContextMenu={(e) => { + setAnchorEl(null); + e.preventDefault(); + }} + > + {menu.map((item) => ( + + {t(item.label)} + + ))} + + + ); +}; +const Widget = styled(Box)(({ theme: { typography } }) => ({ + padding: "3px 6px", + fontSize: 14, + fontFamily: typography.fontFamily, + borderRadius: "4px", +})); diff --git a/src/components/test/test-viewer.tsx b/src/components/test/test-viewer.tsx new file mode 100644 index 00000000..84277341 --- /dev/null +++ b/src/components/test/test-viewer.tsx @@ -0,0 +1,153 @@ +import { forwardRef, useImperativeHandle, useState } from "react"; +import { useLockFn } from "ahooks"; +import { useTranslation } from "react-i18next"; +import { useForm, Controller } from "react-hook-form"; +import { TextField } from "@mui/material"; +import { useVerge } from "@/hooks/use-verge"; +import { BaseDialog, Notice } from "@/components/base"; + +interface Props { + onChange: (uid: string, patch?: Partial) => void; +} + +export interface TestViewerRef { + create: () => void; + edit: (item: IVergeTestItem) => void; +} + +// create or edit the test item +export const TestViewer = forwardRef((props, ref) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [openType, setOpenType] = useState<"new" | "edit">("new"); + const [loading, setLoading] = useState(false); + const { verge, patchVerge } = useVerge(); + const testList = verge?.test_list ?? []; + const { control, watch, register, ...formIns } = useForm({ + defaultValues: { + name: "", + icon: "", + url: "", + }, + }); + + const patchTestList = async (uid: string, patch: Partial) => { + const newList = testList.map((x) => { + if (x.uid === uid) { + return { ...x, ...patch }; + } + return x; + }); + await patchVerge({ ...verge, test_list: newList }); + }; + + useImperativeHandle(ref, () => ({ + create: () => { + setOpenType("new"); + setOpen(true); + }, + edit: (item) => { + if (item) { + Object.entries(item).forEach(([key, value]) => { + formIns.setValue(key as any, value); + }); + } + setOpenType("edit"); + setOpen(true); + }, + })); + + const handleOk = useLockFn( + formIns.handleSubmit(async (form) => { + setLoading(true); + try { + if (!form.name) throw new Error("`Name` should not be null"); + if (!form.url) throw new Error("`Url` should not be null"); + let newList; + let uid; + + if (openType === "new") { + uid = crypto.randomUUID(); + const item = { ...form, uid }; + newList = [...testList, item]; + await patchVerge({ test_list: newList }); + props.onChange(uid); + } else { + if (!form.uid) throw new Error("UID not found"); + uid = form.uid; + + await patchTestList(uid, form); + props.onChange(uid, form); + } + setOpen(false); + setLoading(false); + setTimeout(() => formIns.reset(), 500); + } catch (err: any) { + Notice.error(err.message || err.toString()); + setLoading(false); + } + }) + ); + + const handleClose = () => { + setOpen(false); + setTimeout(() => formIns.reset(), 500); + }; + + const text = { + fullWidth: true, + size: "small", + margin: "normal", + variant: "outlined", + autoComplete: "off", + autoCorrect: "off", + } as const; + + return ( + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + ); +}); diff --git a/src/locales/zh.json b/src/locales/zh.json index e1942ece..5bb2694b 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1,5 +1,6 @@ { "Label-Proxies": "代 理", + "Label-Test": "测 试", "Label-Profiles": "订 阅", "Label-Connections": "连 接", "Label-Logs": "日 志", @@ -11,11 +12,17 @@ "Clear": "清除", "Proxies": "代理", "Proxy Groups": "代理组", + "Test": "测试", "rule": "规则", "global": "全局", "direct": "直连", "script": "脚本", + "Edit": "编辑", + "Icon": "图标", + "Test URL": "测试地址", + "Test All": "测试全部", + "Profiles": "订阅", "Profile URL": "订阅文件链接", "Import": "导入", diff --git a/src/pages/_routers.tsx b/src/pages/_routers.tsx index f53ed487..97eb2ee8 100644 --- a/src/pages/_routers.tsx +++ b/src/pages/_routers.tsx @@ -1,5 +1,6 @@ import LogsPage from "./logs"; import ProxiesPage from "./proxies"; +import TestPage from "./test"; import ProfilesPage from "./profiles"; import SettingsPage from "./settings"; import ConnectionsPage from "./connections"; @@ -11,6 +12,11 @@ export const routers = [ link: "/", ele: ProxiesPage, }, + { + label: "Label-Test", + link: "/test", + ele: TestPage, + }, { label: "Label-Profiles", link: "/profile", diff --git a/src/pages/test.tsx b/src/pages/test.tsx new file mode 100644 index 00000000..a28545f2 --- /dev/null +++ b/src/pages/test.tsx @@ -0,0 +1,164 @@ +import { useEffect, useRef } from "react"; +import { useVerge } from "@/hooks/use-verge"; +import { Box, Button, Grid } from "@mui/material"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, +} from "@dnd-kit/sortable"; + +import { useTranslation } from "react-i18next"; +import { BasePage } from "@/components/base"; +import { TestViewer, TestViewerRef } from "@/components/test/test-viewer"; +import { TestItem } from "@/components/test/test-item"; +import { emit } from "@tauri-apps/api/event"; + +const TestPage = () => { + const { t } = useTranslation(); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + const { verge, mutateVerge, patchVerge } = useVerge(); + + // test list + const testList = verge?.test_list ?? [ + { + uid: crypto.randomUUID(), + name: "Apple", + url: "https://www.apple.com", + icon: "https://www.apple.com/favicon.ico", + }, + { + uid: crypto.randomUUID(), + name: "GitHub", + url: "https://www.github.com", + icon: ``, + }, + { + uid: crypto.randomUUID(), + name: "Google", + url: "https://www.google.com", + icon: ``, + }, + ]; + + const onTestListItemChange = ( + uid: string, + patch?: Partial + ) => { + if (patch) { + const newList = testList.map((x) => { + if (x.uid === uid) { + return { ...x, ...patch }; + } + return x; + }); + mutateVerge({ ...verge, test_list: newList }, false); + } else { + mutateVerge(); + } + }; + + const onDeleteTestListItem = (uid: string) => { + const newList = testList.filter((x) => x.uid !== uid); + patchVerge({ test_list: newList }); + mutateVerge({ ...verge, test_list: newList }, false); + }; + + const reorder = (list: any[], startIndex: number, endIndex: number) => { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + return result; + }; + + const onDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + if (over) { + if (active.id !== over.id) { + let old_index = testList.findIndex((x) => x.uid === active.id); + let new_index = testList.findIndex((x) => x.uid === over.id); + if (old_index < 0 || new_index < 0) { + return; + } + let newList = reorder(testList, old_index, new_index); + await mutateVerge({ ...verge, test_list: newList }, false); + await patchVerge({ test_list: newList }); + } + } + }; + + useEffect(() => { + if (!verge) return; + if (!verge?.test_list) { + patchVerge({ test_list: testList }); + } + }, [verge]); + + const viewerRef = useRef(null); + + return ( + + + + + } + > + + + + { + return x.uid; + })} + > + {testList.map((item) => ( + + viewerRef.current?.edit(item)} + onDelete={onDeleteTestListItem} + /> + + ))} + + + + + + + ); +}; + +export default TestPage; diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 5171eeea..b1a6e44d 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -165,6 +165,10 @@ export async function cmdGetProxyDelay(name: string, url?: string) { return invoke<{ delay: number }>("clash_api_get_proxy_delay", { name, url }); } +export async function cmdTestDelay(url: string) { + return invoke("test_delay", { url }); +} + /// service mode export async function checkService() { diff --git a/src/services/delay.ts b/src/services/delay.ts index a0a3b120..84f1e6cc 100644 --- a/src/services/delay.ts +++ b/src/services/delay.ts @@ -109,17 +109,16 @@ class DelayManager { } formatDelay(delay: number) { - if (delay < 0) return "-"; + if (delay <= 0) return "Error"; if (delay > 1e5) return "Error"; if (delay >= 10000) return "Timeout"; // 10s - return `${delay}`; + return `${delay} ms`; } formatDelayColor(delay: number) { if (delay >= 10000) return "error.main"; - /*if (delay <= 0) return "text.secondary"; + if (delay <= 0) return "error.main"; if (delay > 500) return "warning.main"; - if (delay > 100) return "text.secondary";*/ return "success.main"; } } diff --git a/src/services/types.d.ts b/src/services/types.d.ts index 5563f20d..ff5a53db 100644 --- a/src/services/types.d.ts +++ b/src/services/types.d.ts @@ -154,6 +154,13 @@ interface IProfilesConfig { items?: IProfileItem[]; } +interface IVergeTestItem { + uid: string; + name?: string; + icon?: string; + url: string; +} + interface IVergeConfig { app_log_level?: "trace" | "debug" | "info" | "warn" | "error" | string; language?: string; @@ -194,6 +201,7 @@ interface IVergeConfig { enable_builtin_enhanced?: boolean; auto_log_clean?: 0 | 1 | 2 | 3; proxy_layout_column?: number; + test_list?: IVergeTestItem[]; } type IClashConfigValue = any;