Bläddra i källkod

feat: Add Test Page

MystiPanda 1 år sedan
förälder
incheckning
45a28751af

+ 5 - 0
src-tauri/src/cmds.rs

@@ -261,6 +261,11 @@ pub fn get_portable_flag() -> CmdResult<bool> {
     Ok(*dirs::PORTABLE_FLAG.get().unwrap_or(&false))
 }
 
+#[tauri::command]
+pub async fn test_delay(url: String) -> CmdResult<u32> {
+    Ok(feat::test_delay(url).await.unwrap_or(10000u32))
+}
+
 #[cfg(windows)]
 pub mod service {
     use super::*;

+ 12 - 0
src-tauri/src/config/verge.rs

@@ -88,6 +88,9 @@ pub struct IVerge {
     /// proxy 页面布局 列数
     pub proxy_layout_column: Option<i32>,
 
+    /// 测试网站列表
+    pub test_list: Option<Vec<IVergeTestItem>>,
+
     /// 日志清理
     /// 0: 不清理; 1: 7天; 2: 30天; 3: 90天
     pub auto_log_clean: Option<i32>,
@@ -103,6 +106,14 @@ pub struct IVerge {
     pub verge_mixed_port: Option<u16>,
 }
 
+#[derive(Default, Debug, Clone, Deserialize, Serialize)]
+pub struct IVergeTestItem {
+    pub uid: Option<String>,
+    pub name: Option<String>,
+    pub icon: Option<String>,
+    pub url: Option<String>,
+}
+
 #[derive(Default, Debug, Clone, Deserialize, Serialize)]
 pub struct IVergeTheme {
     pub primary_color: Option<String>,
@@ -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);

+ 36 - 0
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<u32> {
+    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)
+    }
+}

+ 1 - 0
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,

+ 42 - 0
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 },
+  };
+});

+ 213 - 0
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<any>(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 (
+    <Box
+      sx={{
+        transform: CSS.Transform.toString(transform),
+        transition,
+      }}
+    >
+      <TestBox
+        onClick={onEditTest}
+        onContextMenu={(event) => {
+          const { clientX, clientY } = event;
+          setPosition({ top: clientY, left: clientX });
+          setAnchorEl(event.currentTarget);
+          event.preventDefault();
+        }}
+      >
+        <Box
+          position="relative"
+          sx={{ cursor: "move" }}
+          ref={setNodeRef}
+          {...attributes}
+          {...listeners}
+        >
+          {icon ? (
+            <Box sx={{ display: "flex", justifyContent: "center" }}>
+              {icon?.trim().startsWith("http") ? (
+                <img src={icon} height="40px" />
+              ) : (
+                <img
+                  src={`data:image/svg+xml;base64,${btoa(icon)}`}
+                  height="40px"
+                />
+              )}
+            </Box>
+          ) : (
+            <Box sx={{ display: "flex", justifyContent: "center" }}>
+              <LanguageTwoTone sx={{ height: "40px" }} fontSize="large" />
+            </Box>
+          )}
+
+          <Box sx={{ display: "flex", justifyContent: "center" }}>
+            <Typography variant="h6" component="h2" noWrap title={name}>
+              {name}
+            </Typography>
+          </Box>
+        </Box>
+        <Divider sx={{ marginTop: "8px" }} />
+        <Box
+          sx={{
+            display: "flex",
+            justifyContent: "center",
+            marginTop: "8px",
+            color: "primary.main",
+          }}
+        >
+          {delay === -2 && (
+            <Widget>
+              <BaseLoading />
+            </Widget>
+          )}
+
+          {delay === -1 && (
+            <Widget
+              className="the-check"
+              onClick={(e) => {
+                e.preventDefault();
+                e.stopPropagation();
+                onDelay();
+              }}
+              sx={({ palette }) => ({
+                ":hover": { bgcolor: alpha(palette.primary.main, 0.15) },
+              })}
+            >
+              Check
+            </Widget>
+          )}
+
+          {delay >= 0 && (
+            // 显示延迟
+            <Widget
+              className="the-delay"
+              onClick={(e) => {
+                e.preventDefault();
+                e.stopPropagation();
+                onDelay();
+              }}
+              color={delayManager.formatDelayColor(delay)}
+              sx={({ palette }) => ({
+                ":hover": {
+                  bgcolor: alpha(palette.primary.main, 0.15),
+                },
+              })}
+            >
+              {delayManager.formatDelay(delay)}
+            </Widget>
+          )}
+        </Box>
+      </TestBox>
+
+      <Menu
+        open={!!anchorEl}
+        anchorEl={anchorEl}
+        onClose={() => setAnchorEl(null)}
+        anchorPosition={position}
+        anchorReference="anchorPosition"
+        transitionDuration={225}
+        MenuListProps={{ sx: { py: 0.5 } }}
+        onContextMenu={(e) => {
+          setAnchorEl(null);
+          e.preventDefault();
+        }}
+      >
+        {menu.map((item) => (
+          <MenuItem
+            key={item.label}
+            onClick={item.handler}
+            sx={{ minWidth: 120 }}
+            dense
+          >
+            {t(item.label)}
+          </MenuItem>
+        ))}
+      </Menu>
+    </Box>
+  );
+};
+const Widget = styled(Box)(({ theme: { typography } }) => ({
+  padding: "3px 6px",
+  fontSize: 14,
+  fontFamily: typography.fontFamily,
+  borderRadius: "4px",
+}));

+ 153 - 0
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<IVergeTestItem>) => void;
+}
+
+export interface TestViewerRef {
+  create: () => void;
+  edit: (item: IVergeTestItem) => void;
+}
+
+// create or edit the test item
+export const TestViewer = forwardRef<TestViewerRef, Props>((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<IVergeTestItem>({
+    defaultValues: {
+      name: "",
+      icon: "",
+      url: "",
+    },
+  });
+
+  const patchTestList = async (uid: string, patch: Partial<IVergeTestItem>) => {
+    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 (
+    <BaseDialog
+      open={open}
+      title={openType === "new" ? t("Create Test") : t("Edit Test")}
+      contentSx={{ width: 375, pb: 0, maxHeight: "80%" }}
+      okBtn={t("Save")}
+      cancelBtn={t("Cancel")}
+      onClose={handleClose}
+      onCancel={handleClose}
+      onOk={handleOk}
+      loading={loading}
+    >
+      <Controller
+        name="name"
+        control={control}
+        render={({ field }) => (
+          <TextField {...text} {...field} label={t("Name")} />
+        )}
+      />
+      <Controller
+        name="icon"
+        control={control}
+        render={({ field }) => (
+          <TextField
+            {...text}
+            {...field}
+            multiline
+            maxRows={5}
+            label={t("Icon")}
+          />
+        )}
+      />
+      <Controller
+        name="url"
+        control={control}
+        render={({ field }) => (
+          <TextField
+            {...text}
+            {...field}
+            multiline
+            maxRows={3}
+            label={t("Test URL")}
+          />
+        )}
+      />
+    </BaseDialog>
+  );
+});

+ 7 - 0
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": "导入",

+ 6 - 0
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",

+ 164 - 0
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: `<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#000000"/></svg>`,
+    },
+    {
+      uid: crypto.randomUUID(),
+      name: "Google",
+      url: "https://www.google.com",
+      icon: `<svg enable-background="new 0 0 48 48" height="48" viewBox="0 0 48 48" width="48" xmlns="http://www.w3.org/2000/svg"><path d="m43.611 20.083h-1.611v-.083h-18v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657c-3.572-3.329-8.35-5.382-13.618-5.382-11.045 0-20 8.955-20 20s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z" fill="#ffc107"/><path d="m6.306 14.691 6.571 4.819c1.778-4.402 6.084-7.51 11.123-7.51 3.059 0 5.842 1.154 7.961 3.039l5.657-5.657c-3.572-3.329-8.35-5.382-13.618-5.382-7.682 0-14.344 4.337-17.694 10.691z" fill="#ff3d00"/><path d="m24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238c-2.008 1.521-4.504 2.43-7.219 2.43-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025c3.31 6.477 10.032 10.921 17.805 10.921z" fill="#4caf50"/><path d="m43.611 20.083h-1.611v-.083h-18v8h11.303c-.792 2.237-2.231 4.166-4.087 5.571.001-.001.002-.001.003-.002l6.19 5.238c-.438.398 6.591-4.807 6.591-14.807 0-1.341-.138-2.65-.389-3.917z" fill="#1976d2"/></svg>`,
+    },
+  ];
+
+  const onTestListItemChange = (
+    uid: string,
+    patch?: Partial<IVergeTestItem>
+  ) => {
+    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<TestViewerRef>(null);
+
+  return (
+    <BasePage
+      title={t("Test")}
+      header={
+        <Box sx={{ mt: 1, display: "flex", alignItems: "center", gap: 1 }}>
+          <Button
+            variant="contained"
+            size="small"
+            onClick={() => emit("verge://test-all")}
+          >
+            {t("Test All")}
+          </Button>
+          <Button
+            variant="contained"
+            size="small"
+            onClick={() => viewerRef.current?.create()}
+          >
+            {t("New")}
+          </Button>
+        </Box>
+      }
+    >
+      <DndContext
+        sensors={sensors}
+        collisionDetection={closestCenter}
+        onDragEnd={onDragEnd}
+      >
+        <Box sx={{ mb: 4.5 }}>
+          <Grid container spacing={{ xs: 1, lg: 1 }}>
+            <SortableContext
+              items={testList.map((x) => {
+                return x.uid;
+              })}
+            >
+              {testList.map((item) => (
+                <Grid item xs={6} sm={4} md={3} lg={2} key={item.uid}>
+                  <TestItem
+                    id={item.uid}
+                    itemData={item}
+                    onEdit={() => viewerRef.current?.edit(item)}
+                    onDelete={onDeleteTestListItem}
+                  />
+                </Grid>
+              ))}
+            </SortableContext>
+          </Grid>
+        </Box>
+      </DndContext>
+      <TestViewer ref={viewerRef} onChange={onTestListItemChange} />
+    </BasePage>
+  );
+};
+
+export default TestPage;

+ 4 - 0
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<number>("test_delay", { url });
+}
+
 /// service mode
 
 export async function checkService() {

+ 3 - 4
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";
   }
 }

+ 8 - 0
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;