diff --git a/package.json b/package.json index 9687242a..9b2445b2 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,12 @@ "@tauri-apps/plugin-process": "^2.2.0", "@tauri-apps/plugin-shell": "2.2.0", "@tauri-apps/plugin-updater": "2.3.0", + "@types/d3-shape": "^3.1.7", "@types/json-schema": "^7.0.15", "ahooks": "^3.8.4", "axios": "^1.7.9", "cli-color": "^2.0.4", + "d3-shape": "^3.2.0", "dayjs": "1.11.13", "foxact": "^0.2.43", "glob": "^11.0.1", @@ -65,6 +67,7 @@ "react-router-dom": "^6.22.3", "react-transition-group": "^4.4.5", "react-virtuoso": "^4.6.3", + "recharts": "^2.15.1", "sockette": "^2.0.6", "swr": "^2.3.0", "tar": "^7.4.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b001f532..e41b87e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,9 @@ importers: "@tauri-apps/plugin-updater": specifier: 2.3.0 version: 2.3.0 + "@types/d3-shape": + specifier: ^3.1.7 + version: 3.1.7 "@types/json-schema": specifier: ^7.0.15 version: 7.0.15 @@ -76,6 +79,9 @@ importers: cli-color: specifier: ^2.0.4 version: 2.0.4 + d3-shape: + specifier: ^3.2.0 + version: 3.2.0 dayjs: specifier: 1.11.13 version: 1.11.13 @@ -139,6 +145,9 @@ importers: react-virtuoso: specifier: ^4.6.3 version: 4.12.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts: + specifier: ^2.15.1 + version: 2.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) sockette: specifier: ^2.0.6 version: 2.0.6 @@ -2321,6 +2330,60 @@ packages: integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==, } + "@types/d3-array@3.2.1": + resolution: + { + integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==, + } + + "@types/d3-color@3.1.3": + resolution: + { + integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==, + } + + "@types/d3-ease@3.0.2": + resolution: + { + integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==, + } + + "@types/d3-interpolate@3.0.4": + resolution: + { + integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==, + } + + "@types/d3-path@3.1.1": + resolution: + { + integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==, + } + + "@types/d3-scale@4.0.9": + resolution: + { + integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==, + } + + "@types/d3-shape@3.1.7": + resolution: + { + integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==, + } + + "@types/d3-time@3.0.4": + resolution: + { + integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==, + } + + "@types/d3-timer@3.0.2": + resolution: + { + integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==, + } + "@types/debug@4.1.12": resolution: { @@ -2814,6 +2877,83 @@ packages: integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==, } + d3-array@3.2.4: + resolution: + { + integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==, + } + engines: { node: ">=12" } + + d3-color@3.1.0: + resolution: + { + integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==, + } + engines: { node: ">=12" } + + d3-ease@3.0.1: + resolution: + { + integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==, + } + engines: { node: ">=12" } + + d3-format@3.1.0: + resolution: + { + integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==, + } + engines: { node: ">=12" } + + d3-interpolate@3.0.1: + resolution: + { + integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==, + } + engines: { node: ">=12" } + + d3-path@3.1.0: + resolution: + { + integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==, + } + engines: { node: ">=12" } + + d3-scale@4.0.2: + resolution: + { + integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==, + } + engines: { node: ">=12" } + + d3-shape@3.2.0: + resolution: + { + integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==, + } + engines: { node: ">=12" } + + d3-time-format@4.1.0: + resolution: + { + integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==, + } + engines: { node: ">=12" } + + d3-time@3.1.0: + resolution: + { + integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==, + } + engines: { node: ">=12" } + + d3-timer@3.0.1: + resolution: + { + integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==, + } + engines: { node: ">=12" } + d@1.0.2: resolution: { @@ -2846,6 +2986,12 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: + { + integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==, + } + decode-named-character-reference@1.0.2: resolution: { @@ -3015,6 +3161,12 @@ packages: integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==, } + eventemitter3@4.0.7: + resolution: + { + integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==, + } + execa@5.1.1: resolution: { @@ -3034,6 +3186,13 @@ packages: integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==, } + fast-equals@5.2.2: + resolution: + { + integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==, + } + engines: { node: ">=6.0.0" } + fetch-blob@3.2.0: resolution: { @@ -3251,6 +3410,13 @@ packages: integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==, } + internmap@2.0.3: + resolution: + { + integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==, + } + engines: { node: ">=12" } + intersection-observer@0.12.2: resolution: { @@ -4129,6 +4295,12 @@ packages: integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==, } + react-is@18.3.1: + resolution: + { + integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==, + } + react-is@19.0.0: resolution: { @@ -4180,6 +4352,15 @@ packages: peerDependencies: react: ">=16.8" + react-smooth@4.0.4: + resolution: + { + integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==, + } + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-transition-group@4.4.5: resolution: { @@ -4213,6 +4394,22 @@ packages: } engines: { node: ">= 14.18.0" } + recharts-scale@0.4.5: + resolution: + { + integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==, + } + + recharts@2.15.1: + resolution: + { + integrity: sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==, + } + engines: { node: ">=14" } + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + regenerate-unicode-properties@10.2.0: resolution: { @@ -4535,6 +4732,12 @@ packages: } engines: { node: ">=0.12" } + tiny-invariant@1.3.3: + resolution: + { + integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==, + } + to-regex-range@5.0.1: resolution: { @@ -4699,6 +4902,12 @@ packages: integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==, } + victory-vendor@36.9.2: + resolution: + { + integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==, + } + vite-plugin-monaco-editor@1.1.0: resolution: { @@ -6304,6 +6513,30 @@ snapshots: dependencies: "@babel/types": 7.26.7 + "@types/d3-array@3.2.1": {} + + "@types/d3-color@3.1.3": {} + + "@types/d3-ease@3.0.2": {} + + "@types/d3-interpolate@3.0.4": + dependencies: + "@types/d3-color": 3.1.3 + + "@types/d3-path@3.1.1": {} + + "@types/d3-scale@4.0.9": + dependencies: + "@types/d3-time": 3.0.4 + + "@types/d3-shape@3.1.7": + dependencies: + "@types/d3-path": 3.1.1 + + "@types/d3-time@3.0.4": {} + + "@types/d3-timer@3.0.2": {} + "@types/debug@4.1.12": dependencies: "@types/ms": 2.1.0 @@ -6579,6 +6812,44 @@ snapshots: csstype@3.1.3: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.0: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + d@1.0.2: dependencies: es5-ext: 0.10.64 @@ -6592,6 +6863,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decode-named-character-reference@1.0.2: dependencies: character-entities: 2.0.2 @@ -6708,6 +6981,8 @@ snapshots: d: 1.0.2 es5-ext: 0.10.64 + eventemitter3@4.0.7: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -6726,6 +7001,8 @@ snapshots: extend@3.0.2: {} + fast-equals@5.2.2: {} + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -6862,6 +7139,8 @@ snapshots: inline-style-parser@0.2.4: {} + internmap@2.0.3: {} + intersection-observer@0.12.2: {} is-alphabetical@2.0.1: {} @@ -7440,6 +7719,8 @@ snapshots: react-is@16.13.1: {} + react-is@18.3.1: {} + react-is@19.0.0: {} react-markdown@9.0.3(@types/react@18.3.18)(react@18.3.1): @@ -7480,6 +7761,14 @@ snapshots: "@remix-run/router": 1.22.0 react: 18.3.1 + react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + fast-equals: 5.2.2 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: "@babel/runtime": 7.26.7 @@ -7500,6 +7789,23 @@ snapshots: readdirp@4.1.1: {} + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.7 + lodash: 4.17.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + regenerate-unicode-properties@10.2.0: dependencies: regenerate: 1.4.2 @@ -7703,6 +8009,8 @@ snapshots: es5-ext: 0.10.64 next-tick: 1.1.0 + tiny-invariant@1.3.3: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -7795,6 +8103,23 @@ snapshots: "@types/unist": 3.0.3 vfile-message: 4.0.2 + victory-vendor@36.9.2: + dependencies: + "@types/d3-array": 3.2.1 + "@types/d3-ease": 3.0.2 + "@types/d3-interpolate": 3.0.4 + "@types/d3-scale": 4.0.9 + "@types/d3-shape": 3.1.7 + "@types/d3-time": 3.0.4 + "@types/d3-timer": 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-plugin-monaco-editor@1.1.0(monaco-editor@0.52.2): dependencies: monaco-editor: 0.52.2 diff --git a/src-tauri/src/cmd/system.rs b/src-tauri/src/cmd/system.rs index e10d099a..5363d785 100644 --- a/src-tauri/src/cmd/system.rs +++ b/src-tauri/src/cmd/system.rs @@ -4,8 +4,22 @@ use crate::{ module::sysinfo::PlatformSpecification, wrap_err, }; +use once_cell::sync::Lazy; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; use tauri_plugin_clipboard_manager::ClipboardExt; +// 存储应用启动时间的全局变量 +static APP_START_TIME: Lazy = Lazy::new(|| { + // 获取当前系统时间,转换为毫秒级时间戳 + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + + AtomicI64::new(now) +}); + #[tauri::command] pub async fn export_diagnostic_info() -> CmdResult<()> { let sysinfo = PlatformSpecification::new(); @@ -19,6 +33,13 @@ pub async fn export_diagnostic_info() -> CmdResult<()> { Ok(()) } +#[tauri::command] +pub async fn get_system_info() -> CmdResult { + let sysinfo = PlatformSpecification::new(); + let info = format!("{:?}", sysinfo); + Ok(info) +} + /// 获取当前内核运行模式 #[tauri::command] pub async fn get_running_mode() -> Result { @@ -34,3 +55,15 @@ pub async fn get_running_mode() -> Result { pub async fn install_service() -> CmdResult { wrap_err!(service::reinstall_service().await) } + +/// 获取应用的运行时间(毫秒) +#[tauri::command] +pub fn get_app_uptime() -> CmdResult { + let start_time = APP_START_TIME.load(Ordering::Relaxed); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64; + + Ok(now - start_time) +} diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index e268cc54..f29362c2 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -5,6 +5,7 @@ use crate::{ use anyhow::Result; use log::LevelFilter; use serde::{Deserialize, Serialize}; +use serde_json::Value; /// ### `verge.yaml` schema #[derive(Default, Debug, Clone, Deserialize, Serialize)] @@ -105,6 +106,10 @@ pub struct IVerge { /// enable global hotkey pub enable_global_hotkey: Option, + /// 首页卡片设置 + /// 控制首页各个卡片的显示和隐藏 + pub home_cards: Option, + /// 切换代理时自动关闭连接 pub auto_close_connection: Option, @@ -291,6 +296,7 @@ impl IVerge { enable_global_hotkey: Some(true), enable_lite_mode: Some(false), enable_dns_settings: Some(true), + home_cards: None, ..Self::default() } } @@ -374,6 +380,7 @@ impl IVerge { patch!(enable_tray_speed); patch!(enable_lite_mode); patch!(enable_dns_settings); + patch!(home_cards); } /// 在初始化前尝试拿到单例端口的值 @@ -464,6 +471,7 @@ pub struct IVergeResponse { pub enable_tray_speed: Option, pub enable_lite_mode: Option, pub enable_dns_settings: Option, + pub home_cards: Option, } impl From for IVergeResponse { @@ -528,6 +536,7 @@ impl From for IVergeResponse { enable_tray_speed: verge.enable_tray_speed, enable_lite_mode: verge.enable_lite_mode, enable_dns_settings: verge.enable_dns_settings, + home_cards: verge.home_cards, } } } diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 10804b45..8108ecc8 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -7,7 +7,7 @@ use tokio::time::Duration; // Windows only const SERVICE_URL: &str = "http://127.0.0.1:33211"; -const REQUIRED_SERVICE_VERSION: &str = "1.0.1"; // 定义所需的服务版本号 +const REQUIRED_SERVICE_VERSION: &str = "1.0.2"; // 定义所需的服务版本号 #[derive(Debug, Deserialize, Serialize, Clone)] pub struct ResponseBody { diff --git a/src-tauri/src/feat/config.rs b/src-tauri/src/feat/config.rs index db8c2557..c3627cad 100644 --- a/src-tauri/src/feat/config.rs +++ b/src-tauri/src/feat/config.rs @@ -76,6 +76,7 @@ pub async fn patch_verge(patch: IVerge, not_save_file: bool) -> Result<()> { let enable_tray_speed = patch.enable_tray_speed; let enable_global_hotkey = patch.enable_global_hotkey; let tray_event = patch.tray_event; + let home_cards = patch.home_cards.clone(); let res: std::result::Result<(), anyhow::Error> = { let mut should_restart_core = false; @@ -98,6 +99,9 @@ pub async fn patch_verge(patch: IVerge, not_save_file: bool) -> Result<()> { if enable_global_hotkey.is_some() { should_update_verge_config = true; } + if home_cards.is_some() { + should_update_verge_config = true; + } #[cfg(not(target_os = "windows"))] if redir_enabled.is_some() || redir_port.is_some() { should_restart_core = true; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0a10e135..b52508f9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -153,6 +153,7 @@ pub fn run() { // 添加新的命令 cmd::get_running_mode, cmd::install_service, + cmd::get_app_uptime, // clash cmd::get_clash_info, cmd::patch_clash_config, @@ -206,6 +207,8 @@ pub fn run() { cmd::restore_webdav_backup, // export diagnostic info for issue reporting cmd::export_diagnostic_info, + // get system info for display + cmd::get_system_info, ]); #[cfg(debug_assertions)] diff --git a/src/components/home/clash-info-card.tsx b/src/components/home/clash-info-card.tsx new file mode 100644 index 00000000..02c94e6d --- /dev/null +++ b/src/components/home/clash-info-card.tsx @@ -0,0 +1,109 @@ +import { useTranslation } from "react-i18next"; +import { Typography, Stack, Divider } from "@mui/material"; +import { DeveloperBoardOutlined } from "@mui/icons-material"; +import { useClashInfo } from "@/hooks/use-clash"; +import { useClash } from "@/hooks/use-clash"; +import { EnhancedCard } from "./enhanced-card"; +import useSWR from "swr"; +import { getRules } from "@/services/api"; +import { getAppUptime } from "@/services/cmds"; +import { useState } from "react"; + +export const ClashInfoCard = () => { + const { t } = useTranslation(); + const { clashInfo } = useClashInfo(); + const { version: clashVersion } = useClash(); + + // 计算运行时间 + const [uptime, setUptime] = useState("0:00:00"); + + // 使用SWR定期获取应用运行时间 + useSWR( + "appUptime", + async () => { + const uptimeMs = await getAppUptime(); + // 将毫秒转换为时:分:秒格式 + const hours = Math.floor(uptimeMs / 3600000); + const minutes = Math.floor((uptimeMs % 3600000) / 60000); + const seconds = Math.floor((uptimeMs % 60000) / 1000); + setUptime( + `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`, + ); + return uptimeMs; + }, + { + refreshInterval: 1000, // 每秒更新一次 + revalidateOnFocus: false, + dedupingInterval: 500, + }, + ); + + // 获取规则数 + const { data: rulesData } = useSWR("getRules", getRules, { + fallbackData: [], + suspense: false, + revalidateOnFocus: false, + errorRetryCount: 2, + }); + + // 获取规则数据 + const rules = rulesData || []; + + return ( + } + iconColor="warning" + action={null} + > + {clashInfo && ( + + + + {t("Core Version")} + + + {clashVersion || "-"} + + + + + + {t("System Proxy Address")} + + + {clashInfo.server || "-"} + + + + + + {t("Mixed Port")} + + + {clashInfo.mixed_port || "-"} + + + + + + {t("Uptime")} + + + {uptime} + + + + + + {t("Rules Count")} + + + {rules.length} + + + + )} + + ); +}; diff --git a/src/components/home/clash-mode-card.tsx b/src/components/home/clash-mode-card.tsx new file mode 100644 index 00000000..7274de75 --- /dev/null +++ b/src/components/home/clash-mode-card.tsx @@ -0,0 +1,250 @@ +import { useTranslation } from "react-i18next"; +import { Box, Typography, Paper, Stack, Fade } from "@mui/material"; +import { useLockFn } from "ahooks"; +import useSWR from "swr"; +import { closeAllConnections, getClashConfig } from "@/services/api"; +import { patchClashMode } from "@/services/cmds"; +import { useVerge } from "@/hooks/use-verge"; +import { + LanguageRounded, + MultipleStopRounded, + DirectionsRounded, +} from "@mui/icons-material"; +import { useState, useEffect } from "react"; + +export const ClashModeCard = () => { + const { t } = useTranslation(); + const { verge } = useVerge(); + + // 获取当前Clash配置 + const { data: clashConfig, mutate: mutateClash } = useSWR( + "getClashConfig", + getClashConfig, + { + revalidateOnFocus: false, + }, + ); + + // 支持的模式列表 - 添加直连模式 + const modeList = ["rule", "global", "direct"]; + + // 本地状态记录当前模式,提供更快的UI响应 + const [localMode, setLocalMode] = useState("rule"); + + // 当从API获取到当前模式时更新本地状态 + useEffect(() => { + if (clashConfig?.mode) { + setLocalMode(clashConfig.mode.toLowerCase()); + } + }, [clashConfig]); + + // 切换模式的处理函数 + const onChangeMode = useLockFn(async (mode: string) => { + // 如果已经是当前模式,不做任何操作 + if (mode === localMode) return; + + // 立即更新本地UI状态 + setLocalMode(mode); + + // 断开连接(如果启用了设置) + if (verge?.auto_close_connection) { + closeAllConnections(); + } + + try { + await patchClashMode(mode); + // 成功后刷新数据 + mutateClash(); + } catch (error) { + // 如果操作失败,恢复之前的状态 + console.error("Failed to change mode:", error); + if (clashConfig?.mode) { + setLocalMode(clashConfig.mode.toLowerCase()); + } + } + }); + + // 获取模式对应的图标 + const getModeIcon = (mode: string) => { + switch (mode) { + case "rule": + return ; + case "global": + return ; + case "direct": + return ; + default: + return null; + } + }; + + // 获取模式说明文字 + const getModeDescription = (mode: string) => { + switch (mode) { + case "rule": + return t("Rule Mode Description"); + case "global": + return t("Global Mode Description"); + case "direct": + return t("Direct Mode Description"); + default: + return ""; + } + }; + + return ( + + {/* 模式选择按钮组 */} + + {modeList.map((mode) => ( + onChangeMode(mode)} + sx={{ + cursor: "pointer", + px: 2, + py: 1.2, + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 1, + bgcolor: mode === localMode ? "primary.main" : "background.paper", + color: + mode === localMode ? "primary.contrastText" : "text.primary", + borderRadius: 1.5, + transition: "all 0.2s ease-in-out", + position: "relative", + overflow: "visible", + "&:hover": { + transform: "translateY(-1px)", + boxShadow: 1, + }, + "&:active": { + transform: "translateY(1px)", + }, + "&::after": + mode === localMode + ? { + content: '""', + position: "absolute", + bottom: -16, + left: "50%", + width: 2, + height: 16, + bgcolor: "primary.main", + transform: "translateX(-50%)", + } + : {}, + }} + > + {getModeIcon(mode)} + + {t(mode)} + + + ))} + + + {/* 说明文本区域 */} + + {localMode === "rule" && ( + + + {getModeDescription("rule")} + + + )} + + {localMode === "global" && ( + + + {getModeDescription("global")} + + + )} + + {localMode === "direct" && ( + + + {getModeDescription("direct")} + + + )} + + + ); +}; diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx new file mode 100644 index 00000000..3bc4b298 --- /dev/null +++ b/src/components/home/current-proxy-card.tsx @@ -0,0 +1,653 @@ +import { useTranslation } from "react-i18next"; +import { + Box, + Typography, + Stack, + Chip, + Button, + alpha, + useTheme, + Select, + MenuItem, + FormControl, + InputLabel, + SelectChangeEvent, + Tooltip, +} from "@mui/material"; +import { useEffect, useState } from "react"; +import { + RouterOutlined, + SignalWifi4Bar as SignalStrong, + SignalWifi3Bar as SignalGood, + SignalWifi2Bar as SignalMedium, + SignalWifi1Bar as SignalWeak, + SignalWifi0Bar as SignalNone, + WifiOff as SignalError, + ChevronRight, +} from "@mui/icons-material"; +import { useNavigate } from "react-router-dom"; +import { useCurrentProxy } from "@/hooks/use-current-proxy"; +import { EnhancedCard } from "@/components/home/enhanced-card"; +import { getProxies, updateProxy } from "@/services/api"; +import delayManager from "@/services/delay"; + +// 本地存储的键名 +const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group"; +const STORAGE_KEY_PROXY = "clash-verge-selected-proxy"; + +// 代理节点信息接口 +interface ProxyOption { + name: string; +} + +// 将delayManager返回的颜色格式转换为MUI Chip组件需要的格式 +function convertDelayColor( + delayValue: number, +): + | "default" + | "success" + | "warning" + | "error" + | "primary" + | "secondary" + | "info" + | undefined { + const colorStr = delayManager.formatDelayColor(delayValue); + if (!colorStr) return "default"; + + // 从"error.main"这样的格式转为"error" + const mainColor = colorStr.split(".")[0]; + + switch (mainColor) { + case "success": + return "success"; + case "warning": + return "warning"; + case "error": + return "error"; + case "primary": + return "primary"; + default: + return "default"; + } +} + +// 根据延迟值获取合适的WiFi信号图标 +function getSignalIcon(delay: number): { + icon: JSX.Element; + text: string; + color: string; +} { + if (delay < 0) + return { + icon: , + text: "未测试", + color: "text.secondary", + }; + if (delay >= 10000) + return { + icon: , + text: "超时", + color: "error.main", + }; + if (delay >= 500) + return { + icon: , + text: "延迟较高", + color: "error.main", + }; + if (delay >= 300) + return { + icon: , + text: "延迟中等", + color: "warning.main", + }; + if (delay >= 200) + return { + icon: , + text: "延迟良好", + color: "info.main", + }; + return { + icon: , + text: "延迟极佳", + color: "success.main", + }; +} + +export const CurrentProxyCard = () => { + const { t } = useTranslation(); + const { currentProxy, primaryGroupName, mode, refreshProxy } = + useCurrentProxy(); + const navigate = useNavigate(); + const theme = useTheme(); + + // 判断模式 + const isGlobalMode = mode === "global"; + const isDirectMode = mode === "direct"; // 添加直连模式判断 + + // 从本地存储获取初始值,如果是特殊模式或没有存储值则使用默认值 + const getSavedGroup = () => { + // 全局模式使用 GLOBAL 组 + if (isGlobalMode) { + return "GLOBAL"; + } + // 直连模式使用 DIRECT + if (isDirectMode) { + return "DIRECT"; + } + const savedGroup = localStorage.getItem(STORAGE_KEY_GROUP); + return savedGroup || primaryGroupName || "GLOBAL"; + }; + + // 状态管理 + const [groups, setGroups] = useState< + { name: string; now: string; all: string[] }[] + >([]); + const [selectedGroup, setSelectedGroup] = useState(getSavedGroup()); + const [proxyOptions, setProxyOptions] = useState([]); + const [selectedProxy, setSelectedProxy] = useState(""); + const [displayProxy, setDisplayProxy] = useState(null); + const [records, setRecords] = useState>({}); + const [globalProxy, setGlobalProxy] = useState(""); // 存储全局代理 + const [directProxy, setDirectProxy] = useState(null); // 存储直连代理信息 + + // 保存选择的代理组到本地存储 + useEffect(() => { + // 只有在普通模式下才保存到本地存储 + if (selectedGroup && !isGlobalMode && !isDirectMode) { + localStorage.setItem(STORAGE_KEY_GROUP, selectedGroup); + } + }, [selectedGroup, isGlobalMode, isDirectMode]); + + // 保存选择的代理节点到本地存储 + useEffect(() => { + // 只有在普通模式下才保存到本地存储 + if (selectedProxy && !isGlobalMode && !isDirectMode) { + localStorage.setItem(STORAGE_KEY_PROXY, selectedProxy); + } + }, [selectedProxy, isGlobalMode, isDirectMode]); + + // 当模式变化时更新选择的组 + useEffect(() => { + if (isGlobalMode) { + setSelectedGroup("GLOBAL"); + } else if (isDirectMode) { + setSelectedGroup("DIRECT"); + } else if (primaryGroupName) { + const savedGroup = localStorage.getItem(STORAGE_KEY_GROUP); + setSelectedGroup(savedGroup || primaryGroupName); + } + }, [isGlobalMode, isDirectMode, primaryGroupName]); + + // 获取所有代理组和代理信息 + useEffect(() => { + const fetchProxies = async () => { + try { + const data = await getProxies(); + // 保存所有节点记录信息,用于显示详细节点信息 + setRecords(data.records); + + // 检查并存储全局代理信息 + if (data.global) { + setGlobalProxy(data.global.now || ""); + } + + // 查找并存储直连代理信息 + if (data.records && data.records["DIRECT"]) { + setDirectProxy(data.records["DIRECT"]); + } + + const filteredGroups = data.groups + .filter((g) => g.name !== "DIRECT" && g.name !== "REJECT") + .map((g) => ({ + name: g.name, + now: g.now || "", + all: g.all.map((p) => p.name), + })); + + setGroups(filteredGroups); + + // 直连模式处理 + if (isDirectMode) { + // 直连模式下使用 DIRECT 节点 + setSelectedGroup("DIRECT"); + setSelectedProxy("DIRECT"); + + if (data.records && data.records["DIRECT"]) { + setDisplayProxy(data.records["DIRECT"]); + } + + // 设置仅包含 DIRECT 节点的选项 + setProxyOptions([{ name: "DIRECT" }]); + return; + } + + // 全局模式处理 + if (isGlobalMode) { + // 在全局模式下,使用 GLOBAL 组和 data.global.now 作为选中节点 + if (data.global) { + const globalNow = data.global.now || ""; + setSelectedGroup("GLOBAL"); + setSelectedProxy(globalNow); + + if (globalNow && data.records[globalNow]) { + setDisplayProxy(data.records[globalNow]); + } + + // 设置全局组的代理选项 + const options = data.global.all.map((proxy) => ({ + name: proxy.name, + })); + + setProxyOptions(options); + } + return; + } + + // 以下是普通模式的处理逻辑 + let targetGroup = primaryGroupName; + + // 非特殊模式下,尝试从本地存储获取上次选择的代理组 + const savedGroup = localStorage.getItem(STORAGE_KEY_GROUP); + targetGroup = savedGroup || primaryGroupName; + + // 如果目标组在列表中,则选择它 + if (targetGroup && filteredGroups.some((g) => g.name === targetGroup)) { + setSelectedGroup(targetGroup); + + // 设置该组下的代理选项 + const currentGroup = filteredGroups.find( + (g) => g.name === targetGroup, + ); + if (currentGroup) { + // 创建代理选项 + const options = currentGroup.all.map((proxyName) => { + return { name: proxyName }; + }); + + setProxyOptions(options); + + let targetProxy = currentGroup.now; + + const savedProxy = localStorage.getItem(STORAGE_KEY_PROXY); + // 如果有保存的代理节点且该节点在当前组中,则选择它 + if (savedProxy && currentGroup.all.includes(savedProxy)) { + targetProxy = savedProxy; + } + + setSelectedProxy(targetProxy); + + if (targetProxy && data.records[targetProxy]) { + setDisplayProxy(data.records[targetProxy]); + } + } + } else if (filteredGroups.length > 0) { + // 否则选择第一个组 + setSelectedGroup(filteredGroups[0].name); + + // 创建代理选项 + const options = filteredGroups[0].all.map((proxyName) => { + return { name: proxyName }; + }); + + setProxyOptions(options); + setSelectedProxy(filteredGroups[0].now); + + // 更新显示的代理节点信息 + if (filteredGroups[0].now && data.records[filteredGroups[0].now]) { + setDisplayProxy(data.records[filteredGroups[0].now]); + } + } + } catch (error) { + console.error("获取代理信息失败", error); + } + }; + + fetchProxies(); + }, [primaryGroupName, isGlobalMode, isDirectMode]); + + // 当选择的组发生变化时更新代理选项 + useEffect(() => { + // 如果是特殊模式,已在 fetchProxies 中处理 + if (isGlobalMode || isDirectMode) return; + + const group = groups.find((g) => g.name === selectedGroup); + if (group && records) { + // 创建代理选项 + const options = group.all.map((proxyName) => { + return { name: proxyName }; + }); + + setProxyOptions(options); + + let targetProxy = group.now; + + const savedProxy = localStorage.getItem(STORAGE_KEY_PROXY); + // 如果保存的代理节点在当前组中,则选择它 + if (savedProxy && group.all.includes(savedProxy)) { + targetProxy = savedProxy; + } + + setSelectedProxy(targetProxy); + + if (targetProxy && records[targetProxy]) { + setDisplayProxy(records[targetProxy]); + } + } + }, [selectedGroup, groups, records, isGlobalMode, isDirectMode]); + + // 刷新代理信息 + const refreshProxyData = async () => { + try { + const data = await getProxies(); + + // 检查并更新全局代理信息 + if (isGlobalMode && data.global) { + const globalNow = data.global.now || ""; + + setSelectedProxy(globalNow); + + if (globalNow && data.records[globalNow]) { + setDisplayProxy(data.records[globalNow]); + } + + // 更新全局组的代理选项 + const options = data.global.all.map((proxy) => ({ + name: proxy.name, + })); + + setProxyOptions(options); + } + + // 更新直连代理信息 + if (isDirectMode && data.records["DIRECT"]) { + setDirectProxy(data.records["DIRECT"]); + setDisplayProxy(data.records["DIRECT"]); + } + } catch (error) { + console.error("刷新代理信息失败", error); + } + }; + + // 每隔一段时间刷新特殊模式下的代理信息 + useEffect(() => { + if (!isGlobalMode && !isDirectMode) return; + + const refreshInterval = setInterval(refreshProxyData, 3000); + return () => clearInterval(refreshInterval); + }, [isGlobalMode, isDirectMode]); + + // 处理代理组变更 + const handleGroupChange = (event: SelectChangeEvent) => { + // 特殊模式下不允许切换组 + if (isGlobalMode || isDirectMode) return; + + const newGroup = event.target.value; + setSelectedGroup(newGroup); + }; + + // 处理代理节点变更 + const handleProxyChange = async (event: SelectChangeEvent) => { + // 直连模式下不允许切换节点 + if (isDirectMode) return; + + const newProxy = event.target.value; + setSelectedProxy(newProxy); + + // 更新显示的代理节点信息 + if (records[newProxy]) { + setDisplayProxy(records[newProxy]); + } + + try { + // 更新代理设置 + await updateProxy(selectedGroup, newProxy); + setTimeout(() => { + refreshProxy(); + if (isGlobalMode || isDirectMode) { + refreshProxyData(); // 特殊模式下额外刷新数据 + } + }, 300); + } catch (error) { + console.error("更新代理失败", error); + } + }; + + // 导航到代理页面 + const goToProxies = () => { + // 修正路由路径,根据_routers.tsx配置,代理页面的路径是"/" + navigate("/"); + }; + + // 获取要显示的代理节点 + const proxyToDisplay = displayProxy || currentProxy; + + // 获取当前节点的延迟 + const currentDelay = proxyToDisplay + ? delayManager.getDelayFix(proxyToDisplay, selectedGroup) + : -1; + + // 获取信号图标 + const signalInfo = getSignalIcon(currentDelay); + + // 自定义渲染选择框中的值 + const renderProxyValue = (selected: string) => { + if (!selected || !records[selected]) return selected; + + const delayValue = delayManager.getDelayFix( + records[selected], + selectedGroup, + ); + + return ( + + {selected} + + + ); + }; + + return ( + + + {proxyToDisplay ? signalInfo.icon : } + + + } + iconColor={proxyToDisplay ? "primary" : undefined} + action={ + + } + > + {proxyToDisplay ? ( + + {/* 代理节点信息显示 */} + + + + {proxyToDisplay.name} + + + + + {proxyToDisplay.type} + + {isGlobalMode && ( + + )} + {isDirectMode && ( + + )} + {/* 节点特性 */} + {proxyToDisplay.udp && ( + + )} + {proxyToDisplay.tfo && ( + + )} + {proxyToDisplay.xudp && ( + + )} + {proxyToDisplay.mptcp && ( + + )} + {proxyToDisplay.smux && ( + + )} + + + + {/* 显示延迟 */} + {proxyToDisplay && !isDirectMode && ( + + )} + + {/* 代理组选择器 */} + + {t("Group")} + + + + {/* 代理节点选择器 */} + + {t("Proxy")} + + + + ) : ( + + + {t("No active proxy node")} + + + )} + + ); +}; diff --git a/src/components/home/enhanced-card.tsx b/src/components/home/enhanced-card.tsx new file mode 100644 index 00000000..396b8a8e --- /dev/null +++ b/src/components/home/enhanced-card.tsx @@ -0,0 +1,94 @@ +import { Box, Typography, alpha, useTheme } from "@mui/material"; +import { ReactNode } from "react"; + +// 自定义卡片组件接口 +export interface EnhancedCardProps { + title: ReactNode; + icon: ReactNode; + action?: ReactNode; + children: ReactNode; + iconColor?: + | "primary" + | "secondary" + | "error" + | "warning" + | "info" + | "success"; + minHeight?: number | string; + noContentPadding?: boolean; +} + +// 自定义卡片组件 +export const EnhancedCard = ({ + title, + icon, + action, + children, + iconColor = "primary", + minHeight, + noContentPadding = false, +}: EnhancedCardProps) => { + const theme = useTheme(); + const isDark = theme.palette.mode === "dark"; + + return ( + + + + + {icon} + + {typeof title === "string" ? ( + + {title} + + ) : ( + title + )} + + {action} + + + {children} + + + ); +}; diff --git a/src/components/home/enhanced-traffic-graph.tsx b/src/components/home/enhanced-traffic-graph.tsx new file mode 100644 index 00000000..19ed839a --- /dev/null +++ b/src/components/home/enhanced-traffic-graph.tsx @@ -0,0 +1,382 @@ +import { + forwardRef, + useImperativeHandle, + useState, + useEffect, + useCallback, + useMemo, + ReactElement, +} from "react"; +import { Box, useTheme } from "@mui/material"; +import parseTraffic from "@/utils/parse-traffic"; +import { useTranslation } from "react-i18next"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + AreaChart, + Area, +} from "recharts"; + +// 流量数据项接口 +export interface ITrafficItem { + up: number; + down: number; + timestamp?: number; +} + +// 组件对外暴露的方法 +export interface EnhancedTrafficGraphRef { + appendData: (data: ITrafficItem) => void; + toggleStyle: () => void; +} + +// 时间范围类型 +type TimeRange = 1 | 5 | 10; // 分钟 + +/** + * 增强型流量图表组件 + * 基于 Recharts 实现,支持线图和面积图两种模式 + */ +export const EnhancedTrafficGraph = forwardRef( + (props, ref) => { + const theme = useTheme(); + const { t } = useTranslation(); + + // 时间范围状态(默认10分钟) + const [timeRange, setTimeRange] = useState(10); + + // 根据时间范围计算保留的数据点数量 + const getMaxPointsByTimeRange = useCallback( + (minutes: TimeRange): number => minutes * 60, // 每分钟60个点(每秒1个点) + [], + ); + + // 最大数据点数量 - 基于选择的时间范围 + const MAX_BUFFER_SIZE = useMemo( + () => getMaxPointsByTimeRange(10), + [getMaxPointsByTimeRange], + ); + + // 图表样式:line 或 area + const [chartStyle, setChartStyle] = useState<"line" | "area">("area"); + + // 创建一个明确的类型 + type DataPoint = ITrafficItem & { name: string; timestamp: number }; + + // 完整数据缓冲区 - 保存10分钟的数据 + const [dataBuffer, setDataBuffer] = useState([]); + + // 当前显示的数据点 - 根据选定的时间范围从缓冲区过滤 + const dataPoints = useMemo(() => { + if (dataBuffer.length === 0) return []; + // 根据当前时间范围计算需要显示的点数 + const pointsToShow = getMaxPointsByTimeRange(timeRange); + // 从缓冲区中获取最新的数据点 + return dataBuffer.slice(-pointsToShow); + }, [dataBuffer, timeRange, getMaxPointsByTimeRange]); + + // 颜色配置 + const colors = useMemo( + () => ({ + up: theme.palette.secondary.main, + down: theme.palette.primary.main, + grid: theme.palette.divider, + tooltip: theme.palette.background.paper, + text: theme.palette.text.primary, + }), + [theme], + ); + + // 切换时间范围 + const handleTimeRangeClick = useCallback(() => { + setTimeRange((prevRange) => { + // 在1、5、10分钟之间循环切换 + if (prevRange === 1) return 5; + if (prevRange === 5) return 10; + return 1; + }); + }, []); + + // 初始化空数据缓冲区 + useEffect(() => { + // 生成10分钟的初始数据点 + const now = Date.now(); + const tenMinutesAgo = now - 10 * 60 * 1000; + + // 创建600个点作为初始缓冲区 + const initialBuffer: DataPoint[] = Array.from( + { length: MAX_BUFFER_SIZE }, + (_, index) => { + // 计算每个点的时间 + const pointTime = + tenMinutesAgo + index * ((10 * 60 * 1000) / MAX_BUFFER_SIZE); + const date = new Date(pointTime); + + return { + up: 0, + down: 0, + timestamp: pointTime, + name: date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }), + }; + }, + ); + + setDataBuffer(initialBuffer); + }, [MAX_BUFFER_SIZE]); + + // 添加数据点方法 + const appendData = useCallback((data: ITrafficItem) => { + // 安全处理数据 + const safeData = { + up: typeof data.up === "number" && !isNaN(data.up) ? data.up : 0, + down: + typeof data.down === "number" && !isNaN(data.down) ? data.down : 0, + }; + + setDataBuffer((prev) => { + // 使用提供的时间戳或当前时间 + const timestamp = data.timestamp || Date.now(); + const date = new Date(timestamp); + + // 带时间标签的新数据点 + const newPoint: DataPoint = { + ...safeData, + name: date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }), + timestamp: timestamp, + }; + + // 更新缓冲区,保持最大长度 + return [...prev.slice(1), newPoint]; + }); + }, []); + + // 切换图表样式 + const toggleStyle = useCallback(() => { + setChartStyle((prev) => (prev === "line" ? "area" : "line")); + }, []); + + // 暴露方法给父组件 + useImperativeHandle( + ref, + () => ({ + appendData, + toggleStyle, + }), + [appendData, toggleStyle], + ); + + // 格式化工具提示内容 + const formatTooltip = (value: number) => { + const [num, unit] = parseTraffic(value); + return [`${num} ${unit}/s`, ""]; + }; + + // Y轴刻度格式化 + const formatYAxis = (value: number) => { + const [num, unit] = parseTraffic(value); + return `${num}${unit}`; + }; + + // 格式化X轴标签 + const formatXLabel = useCallback((value: string) => { + if (!value) return ""; + // 只显示小时和分钟 + const parts = value.split(":"); + return `${parts[0]}:${parts[1]}`; + }, []); + + // 获取当前时间范围文本 + const getTimeRangeText = useCallback(() => { + return t("{{time}} Minutes", { time: timeRange }); + }, [timeRange, t]); + + // 渲染图表内的标签 + const renderInnerLabels = () => ( + <> + {/* 上传标签 - 右上角 */} + + {t("Upload")} + + + {/* 下载标签 - 右上角下方 */} + + {t("Download")} + + + ); + + // 共享图表配置 + const commonProps = { + data: dataPoints, + margin: { top: 10, right: 20, left: 0, bottom: 0 }, + }; + + // 曲线类型 - 使用平滑曲线 + const curveType = "basis"; + + // 共享图表子组件 + const commonChildren = ( + <> + + + + `${t("Time")}: ${label}`} + contentStyle={{ + backgroundColor: colors.tooltip, + borderColor: colors.grid, + borderRadius: 4, + }} + itemStyle={{ color: colors.text }} + isAnimationActive={false} + /> + + {/* 可点击的时间范围标签 */} + + + {getTimeRangeText()} + + + + ); + + // 渲染图表 - 线图或面积图 + const renderChart = () => { + // 共享的线条/区域配置 + const commonLineProps = { + dot: false, + strokeWidth: 2, + connectNulls: false, + activeDot: { r: 4, strokeWidth: 1 }, + }; + + return chartStyle === "line" ? ( + + {commonChildren} + + + {renderInnerLabels()} + + ) : ( + + {commonChildren} + + + {renderInnerLabels()} + + ); + }; + + return ( + + + {renderChart()} + + + ); + }, +); diff --git a/src/components/home/enhanced-traffic-stats.tsx b/src/components/home/enhanced-traffic-stats.tsx new file mode 100644 index 00000000..8feadf3a --- /dev/null +++ b/src/components/home/enhanced-traffic-stats.tsx @@ -0,0 +1,394 @@ +import { useState, useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { + Typography, + Paper, + alpha, + useTheme, + PaletteColor, +} from "@mui/material"; +import Grid from "@mui/material/Grid2"; +import { + ArrowUpwardRounded, + ArrowDownwardRounded, + MemoryRounded, + LinkRounded, + CloudUploadRounded, + CloudDownloadRounded, +} from "@mui/icons-material"; +import { + EnhancedTrafficGraph, + EnhancedTrafficGraphRef, + ITrafficItem, +} from "./enhanced-traffic-graph"; +import { useVisibility } from "@/hooks/use-visibility"; +import { useClashInfo } from "@/hooks/use-clash"; +import { useVerge } from "@/hooks/use-verge"; +import { createAuthSockette } from "@/utils/websocket"; +import parseTraffic from "@/utils/parse-traffic"; +import { getConnections, isDebugEnabled, gc } from "@/services/api"; +import { ReactNode } from "react"; + +interface MemoryUsage { + inuse: number; + oslimit?: number; +} + +interface TrafficStatData { + uploadTotal: number; + downloadTotal: number; + activeConnections: number; +} + +interface StatCardProps { + icon: ReactNode; + title: string; + value: string | number; + unit: string; + color: "primary" | "secondary" | "error" | "warning" | "info" | "success"; + onClick?: () => void; +} + +// 全局变量类型定义 +declare global { + interface Window { + animationFrameId?: number; + lastTrafficData?: { + up: number; + down: number; + }; + } +} + +export const EnhancedTrafficStats = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const { clashInfo } = useClashInfo(); + const { verge } = useVerge(); + const trafficRef = useRef(null); + const pageVisible = useVisibility(); + const [isDebug, setIsDebug] = useState(false); + const [trafficStats, setTrafficStats] = useState({ + uploadTotal: 0, + downloadTotal: 0, + activeConnections: 0, + }); + + // 是否显示流量图表 + const trafficGraph = verge?.traffic_graph ?? true; + + // 获取连接数据 + const fetchConnections = async () => { + try { + const connections = await getConnections(); + if (connections && connections.connections) { + const uploadTotal = connections.connections.reduce( + (sum, conn) => sum + conn.upload, + 0, + ); + const downloadTotal = connections.connections.reduce( + (sum, conn) => sum + conn.download, + 0, + ); + + setTrafficStats({ + uploadTotal, + downloadTotal, + activeConnections: connections.connections.length, + }); + } + } catch (err) { + console.error("Failed to fetch connections:", err); + } + }; + + // 定期更新连接数据 + useEffect(() => { + if (pageVisible) { + fetchConnections(); + const intervalId = setInterval(fetchConnections, 5000); + return () => clearInterval(intervalId); + } + }, [pageVisible]); + + // 检查是否支持调试 + useEffect(() => { + isDebugEnabled().then((flag) => setIsDebug(flag)); + }, []); + + // 为流量数据和内存数据准备状态 + const [trafficData, setTrafficData] = useState({ + up: 0, + down: 0, + }); + const [memoryData, setMemoryData] = useState({ inuse: 0 }); + + // 使用 WebSocket 连接获取流量数据 + useEffect(() => { + if (!clashInfo || !pageVisible) return; + + const { server, secret = "" } = clashInfo; + if (!server) return; + + const socket = createAuthSockette(`${server}/traffic`, secret, { + onmessage(event) { + try { + const data = JSON.parse(event.data) as ITrafficItem; + if ( + data && + typeof data.up === "number" && + typeof data.down === "number" + ) { + setTrafficData({ + up: isNaN(data.up) ? 0 : data.up, + down: isNaN(data.down) ? 0 : data.down, + }); + + if (trafficRef.current) { + const lastData = { + up: isNaN(data.up) ? 0 : data.up, + down: isNaN(data.down) ? 0 : data.down, + }; + + if (!window.lastTrafficData) { + window.lastTrafficData = { ...lastData }; + } + + trafficRef.current.appendData({ + up: lastData.up, + down: lastData.down, + timestamp: Date.now(), + }); + + window.lastTrafficData = { ...lastData }; + + if (window.animationFrameId) { + cancelAnimationFrame(window.animationFrameId); + window.animationFrameId = undefined; + } + } + } + } catch (err) { + console.error("[Traffic] 解析数据错误:", err); + } + }, + }); + + return () => socket.close(); + }, [clashInfo, pageVisible]); + + // 使用 WebSocket 连接获取内存数据 + useEffect(() => { + if (!clashInfo || !pageVisible) return; + + const { server, secret = "" } = clashInfo; + if (!server) return; + + const socket = createAuthSockette(`${server}/memory`, secret, { + onmessage(event) { + try { + const data = JSON.parse(event.data) as MemoryUsage; + if (data && typeof data.inuse === "number") { + setMemoryData({ + inuse: isNaN(data.inuse) ? 0 : data.inuse, + oslimit: data.oslimit, + }); + } + } catch (err) { + console.error("[Memory] 解析数据错误:", err); + } + }, + }); + + return () => socket.close(); + }, [clashInfo, pageVisible]); + + // 解析流量数据 + const [up, upUnit] = parseTraffic(trafficData.up); + const [down, downUnit] = parseTraffic(trafficData.down); + const [inuse, inuseUnit] = parseTraffic(memoryData.inuse); + const [uploadTotal, uploadTotalUnit] = parseTraffic(trafficStats.uploadTotal); + const [downloadTotal, downloadTotalUnit] = parseTraffic( + trafficStats.downloadTotal, + ); + + // 获取调色板颜色 + const getColorFromPalette = (colorName: string) => { + const palette = theme.palette; + if ( + colorName in palette && + palette[colorName as keyof typeof palette] && + "main" in (palette[colorName as keyof typeof palette] as PaletteColor) + ) { + return (palette[colorName as keyof typeof palette] as PaletteColor).main; + } + return palette.primary.main; + }; + + // 统计卡片组件 + const CompactStatCard = ({ + icon, + title, + value, + unit, + color, + }: StatCardProps) => ( + + {/* 图标容器 */} + + {icon} + + + {/* 文本内容 */} + + + {title} + + + + {value} + + + {unit} + + + + + ); + + // 渲染流量图表 + const renderTrafficGraph = () => { + if (!trafficGraph || !pageVisible) return null; + + return ( + trafficRef.current?.toggleStyle()} + > +
+ + {isDebug && ( +
+ DEBUG: {!!trafficRef.current ? "图表已初始化" : "图表未初始化"} +
+ {new Date().toISOString().slice(11, 19)} +
+ )} +
+
+ ); + }; + + // 统计卡片配置 + const statCards = [ + { + icon: , + title: t("Upload Speed"), + value: up, + unit: `${upUnit}/s`, + color: "secondary" as const, + }, + { + icon: , + title: t("Download Speed"), + value: down, + unit: `${downUnit}/s`, + color: "primary" as const, + }, + { + icon: , + title: t("Active Connections"), + value: trafficStats.activeConnections, + unit: "", + color: "success" as const, + }, + { + icon: , + title: t("Uploaded"), + value: uploadTotal, + unit: uploadTotalUnit, + color: "secondary" as const, + }, + { + icon: , + title: t("Downloaded"), + value: downloadTotal, + unit: downloadTotalUnit, + color: "primary" as const, + }, + { + icon: , + title: t("Memory Usage"), + value: inuse, + unit: inuseUnit, + color: "error" as const, + onClick: isDebug ? async () => await gc() : undefined, + }, + ]; + + return ( + + + {/* 流量图表区域 */} + {renderTrafficGraph()} + + {/* 统计卡片区域 */} + {statCards.map((card, index) => ( + + + + ))} + + ); +}; diff --git a/src/components/home/home-profile-card.tsx b/src/components/home/home-profile-card.tsx new file mode 100644 index 00000000..0d0468a4 --- /dev/null +++ b/src/components/home/home-profile-card.tsx @@ -0,0 +1,297 @@ +import { useTranslation } from "react-i18next"; +import { + Box, + Typography, + Button, + Stack, + LinearProgress, + alpha, + useTheme, + Link, + keyframes, +} from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import { + CloudUploadOutlined, + StorageOutlined, + UpdateOutlined, + DnsOutlined, + SpeedOutlined, + EventOutlined, + LaunchOutlined, +} from "@mui/icons-material"; +import dayjs from "dayjs"; +import parseTraffic from "@/utils/parse-traffic"; +import { useState } from "react"; +import { openWebUrl, updateProfile } from "@/services/cmds"; +import { useLockFn } from "ahooks"; +import { Notice } from "@/components/base"; +import { EnhancedCard } from "./enhanced-card"; + +// 定义旋转动画 +const round = keyframes` + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +`; + +// 辅助函数解析URL和过期时间 +function parseUrl(url?: string) { + if (!url) return "-"; + if (url.startsWith("http")) return new URL(url).host; + return "local"; +} + +function parseExpire(expire?: number) { + if (!expire) return "-"; + return dayjs(expire * 1000).format("YYYY-MM-DD"); +} + +// 使用类型定义,而不是导入 +interface ProfileExtra { + upload: number; + download: number; + total: number; + expire: number; +} + +export interface ProfileItem { + uid: string; + type?: "local" | "remote" | "merge" | "script"; + name?: string; + desc?: string; + file?: string; + url?: string; + updated?: number; + extra?: ProfileExtra; + home?: string; + option?: any; // 添加option以兼容原始类型 +} + +export interface HomeProfileCardProps { + current: ProfileItem | null | undefined; +} + +export const HomeProfileCard = ({ current }: HomeProfileCardProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const theme = useTheme(); + + // 更新当前订阅 + const [updating, setUpdating] = useState(false); + const onUpdateProfile = useLockFn(async () => { + if (!current?.uid) return; + + setUpdating(true); + try { + await updateProfile(current.uid); + Notice.success(t("Update subscription successfully")); + } catch (err: any) { + Notice.error(err?.message || err.toString()); + } finally { + setUpdating(false); + } + }); + + // 导航到订阅页面 + const goToProfiles = () => { + navigate("/profile"); + }; + + return ( + current.home && openWebUrl(current.home)} + sx={{ + display: "inline-flex", + alignItems: "center", + color: "inherit", + textDecoration: "none", + }} + > + {current.name} + + + ) : ( + current.name + ) + ) : ( + t("Profiles") + ) + } + icon={} + iconColor="info" + action={ + current && ( + + ) + } + > + {current ? ( + // 已导入订阅,显示详情 + + + {current.url && ( + + + + {t("From")}:{" "} + {current.home ? ( + current.home && openWebUrl(current.home)} + sx={{ display: "inline-flex", alignItems: "center" }} + > + {parseUrl(current.url)} + + + ) : ( + + {parseUrl(current.url)} + + )} + + + )} + + {current.updated && ( + + + + {t("Update Time")}:{" "} + + {dayjs(current.updated * 1000).format("YYYY-MM-DD HH:mm")} + + + + )} + + {current.extra && ( + <> + + + + {t("Used / Total")}:{" "} + + {parseTraffic( + current.extra.upload + current.extra.download, + )}{" "} + / {parseTraffic(current.extra.total)} + + + + + {current.extra.expire > 0 && ( + + + + {t("Expire Time")}:{" "} + + {parseExpire(current.extra.expire)} + + + + )} + + + + {Math.min( + Math.round( + ((current.extra.download + current.extra.upload) * + 100) / + (current.extra.total + 0.01), + ) + 1, + 100, + )} + % + + + + + )} + + + ) : ( + // 未导入订阅,显示导入按钮 + + + + {t("Import")} {t("Profiles")} + + + {t("Click to import subscription")} + + + )} + + ); +}; diff --git a/src/components/home/ip-info-card.tsx b/src/components/home/ip-info-card.tsx new file mode 100644 index 00000000..926dc586 --- /dev/null +++ b/src/components/home/ip-info-card.tsx @@ -0,0 +1,298 @@ +import { useTranslation } from "react-i18next"; +import { + Box, + Typography, + Button, + Skeleton, + IconButton, + useTheme, +} from "@mui/material"; +import { + LocationOnOutlined, + RefreshOutlined, + VisibilityOutlined, + VisibilityOffOutlined, +} from "@mui/icons-material"; +import { EnhancedCard } from "./enhanced-card"; +import { getIpInfo } from "@/services/api"; +import { useState, useEffect, useCallback } from "react"; + +// 定义刷新时间(秒) +const IP_REFRESH_SECONDS = 300; + +// IP信息卡片组件 +export const IpInfoCard = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const [ipInfo, setIpInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [showIp, setShowIp] = useState(false); + const [countdown, setCountdown] = useState(IP_REFRESH_SECONDS); + + // 获取IP信息 + const fetchIpInfo = useCallback(async () => { + try { + setLoading(true); + setError(""); + const data = await getIpInfo(); + setIpInfo(data); + setCountdown(IP_REFRESH_SECONDS); + } catch (err: any) { + setError(err.message || t("Failed to get IP info")); + } finally { + setLoading(false); + } + }, [t]); + + // 组件加载时获取IP信息 + useEffect(() => { + fetchIpInfo(); + }, [fetchIpInfo]); + + // 倒计时自动刷新 + useEffect(() => { + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + fetchIpInfo(); + return IP_REFRESH_SECONDS; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [fetchIpInfo]); + + // 刷新按钮点击处理 + const handleRefresh = () => { + fetchIpInfo(); + }; + + // 切换显示/隐藏IP + const toggleShowIp = () => { + setShowIp(!showIp); + }; + + // 获取国旗表情 + const getCountryFlag = (countryCode: string) => { + if (!countryCode) return ""; + const codePoints = countryCode + .toUpperCase() + .split("") + .map((char) => 127397 + char.charCodeAt(0)); + return String.fromCodePoint(...codePoints); + }; + + // 信息项组件 - 默认不换行,但在需要时可以换行 + const InfoItem = ({ label, value }: { label: string; value: string }) => ( + + + {label}: + + + {value || t("Unknown")} + + + ); + + return ( + } + iconColor="info" + action={ + + + + } + > + + {loading ? ( + + + + + + + ) : error ? ( + + + {error} + + + + ) : ( + <> + + {/* 左侧:国家和IP地址 */} + + + + {getCountryFlag(ipInfo?.country_code)} + + + {ipInfo?.country || t("Unknown")} + + + + + + {t("IP")}: + + + + {showIp ? ipInfo?.ip : "••••••••••"} + + + {showIp ? ( + + ) : ( + + )} + + + + + + + + {/* 右侧:组织、ISP和位置信息 */} + + + + + + + + + + + + + + {t("Auto refresh")}: {countdown}s + + + {ipInfo?.country_code}, {ipInfo?.longitude?.toFixed(2)},{" "} + {ipInfo?.latitude?.toFixed(2)} + + + + )} + + + ); +}; diff --git a/src/components/home/proxy-tun-card.tsx b/src/components/home/proxy-tun-card.tsx new file mode 100644 index 00000000..cfe14319 --- /dev/null +++ b/src/components/home/proxy-tun-card.tsx @@ -0,0 +1,280 @@ +import { useTranslation } from "react-i18next"; +import { + Box, + Typography, + Stack, + Paper, + Tooltip, + alpha, + useTheme, + Button, + Fade, +} from "@mui/material"; +import { useState, useEffect } from "react"; +import ProxyControlSwitches from "@/components/shared/ProxyControlSwitches"; +import { Notice } from "@/components/base"; +import { + LanguageRounded, + ComputerRounded, + TroubleshootRounded, + HelpOutlineRounded, +} from "@mui/icons-material"; +import useSWR from "swr"; +import { + getSystemProxy, + getAutotemProxy, + getRunningMode, +} from "@/services/cmds"; + +export const ProxyTunCard = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState("system"); + + // 获取代理状态信息 + const { data: sysproxy } = useSWR("getSystemProxy", getSystemProxy); + const { data: autoproxy } = useSWR("getAutotemProxy", getAutotemProxy); + const { data: runningMode } = useSWR("getRunningMode", getRunningMode); + + // 是否以sidecar模式运行 + const isSidecarMode = runningMode === "sidecar"; + + // 处理错误 + const handleError = (err: Error) => { + setError(err.message); + Notice.error(err.message || err.toString(), 3000); + }; + + // 用户提示文本 + const getTabDescription = (tab: string) => { + switch (tab) { + case "system": + return sysproxy?.enable + ? t("System Proxy Enabled") + : t("System Proxy Disabled"); + case "tun": + return isSidecarMode + ? t("TUN Mode Service Required") + : t("TUN Mode Intercept Info"); + default: + return ""; + } + }; + + return ( + + {/* 选项卡 */} + + setActiveTab("system")} + sx={{ + cursor: "pointer", + px: 2, + py: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 1, + bgcolor: + activeTab === "system" ? "primary.main" : "background.paper", + color: + activeTab === "system" ? "primary.contrastText" : "text.primary", + borderRadius: 1.5, + flex: 1, + maxWidth: 160, + transition: "all 0.2s ease-in-out", + position: "relative", + "&:hover": { + transform: "translateY(-1px)", + boxShadow: 1, + }, + "&:after": + activeTab === "system" + ? { + content: '""', + position: "absolute", + bottom: -9, + left: "50%", + width: 2, + height: 9, + bgcolor: "primary.main", + transform: "translateX(-50%)", + } + : {}, + }} + > + + + {t("System Proxy")} + + {sysproxy?.enable && ( + + )} + + setActiveTab("tun")} + sx={{ + cursor: "pointer", + px: 2, + py: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: 1, + bgcolor: activeTab === "tun" ? "primary.main" : "background.paper", + color: + activeTab === "tun" ? "primary.contrastText" : "text.primary", + borderRadius: 1.5, + flex: 1, + maxWidth: 160, + transition: "all 0.2s ease-in-out", + position: "relative", + "&:hover": { + transform: "translateY(-1px)", + boxShadow: 1, + }, + "&:after": + activeTab === "tun" + ? { + content: '""', + position: "absolute", + bottom: -9, + left: "50%", + width: 2, + height: 9, + bgcolor: "primary.main", + transform: "translateX(-50%)", + } + : {}, + }} + > + + + {t("Tun Mode")} + + + + + {/* 说明文本区域 */} + + {activeTab === "system" && ( + + + {getTabDescription("system")} + + + + + + )} + + {activeTab === "tun" && ( + + + {getTabDescription("tun")} + + + + + + )} + + + {/* 控制开关部分 */} + + + + + ); +}; diff --git a/src/components/home/system-info-card.tsx b/src/components/home/system-info-card.tsx new file mode 100644 index 00000000..bc4b7b29 --- /dev/null +++ b/src/components/home/system-info-card.tsx @@ -0,0 +1,240 @@ +import { useTranslation } from "react-i18next"; +import { Typography, Stack, Divider, Chip, IconButton } from "@mui/material"; +import { InfoOutlined, SettingsOutlined } from "@mui/icons-material"; +import { useVerge } from "@/hooks/use-verge"; +import { EnhancedCard } from "./enhanced-card"; +import useSWR from "swr"; +import { getRunningMode, getSystemInfo, installService } from "@/services/cmds"; +import { useNavigate } from "react-router-dom"; +import { version as appVersion } from "@root/package.json"; +import { useEffect, useState } from "react"; +import { check as checkUpdate } from "@tauri-apps/plugin-updater"; +import { useLockFn } from "ahooks"; +import { Notice } from "@/components/base"; + +export const SystemInfoCard = () => { + const { t } = useTranslation(); + const { verge, patchVerge } = useVerge(); + const navigate = useNavigate(); + + // 获取运行模式 + const { data: runningMode = "sidecar", mutate: mutateRunningMode } = useSWR( + "getRunningMode", + getRunningMode, + ); + + // 获取系统信息 + const [osInfo, setOsInfo] = useState(""); + useEffect(() => { + getSystemInfo() + .then((info) => { + const lines = info.split("\n"); + if (lines.length > 0) { + // 提取系统名称和版本信息 + const sysNameLine = lines[0]; // System Name: xxx + const sysVersionLine = lines[1]; // System Version: xxx + + const sysName = sysNameLine.split(": ")[1] || ""; + const sysVersion = sysVersionLine.split(": ")[1] || ""; + + setOsInfo(`${sysName} ${sysVersion}`); + } + }) + .catch((err) => { + console.error("Error getting system info:", err); + }); + }, []); + + // 获取最后检查更新时间 + const [lastCheckUpdate, setLastCheckUpdate] = useState("-"); + + // 在组件挂载时检查本地存储中的最后更新时间 + useEffect(() => { + // 获取最后检查更新时间 + const lastCheck = localStorage.getItem("last_check_update"); + if (lastCheck) { + try { + const timestamp = parseInt(lastCheck, 10); + if (!isNaN(timestamp)) { + const date = new Date(timestamp); + setLastCheckUpdate(date.toLocaleString()); + } + } catch (e) { + console.error("Error parsing last check update time", e); + } + } else if (verge?.auto_check_update) { + // 如果启用了自动检查更新但没有最后检查时间记录,则触发一次检查 + const now = Date.now(); + localStorage.setItem("last_check_update", now.toString()); + setLastCheckUpdate(new Date(now).toLocaleString()); + + // 延迟执行检查更新,避免在应用启动时立即执行 + setTimeout(() => { + checkUpdate().catch((e) => console.error("Error checking update:", e)); + }, 5000); + } + }, [verge?.auto_check_update]); + + // 监听 checkUpdate 调用并更新时间 + useSWR( + "checkUpdate", + async () => { + // 更新最后检查时间 + const now = Date.now(); + localStorage.setItem("last_check_update", now.toString()); + setLastCheckUpdate(new Date(now).toLocaleString()); + + // 实际执行检查更新 + return await checkUpdate(); + }, + { + revalidateOnFocus: false, + refreshInterval: 24 * 60 * 60 * 1000, // 每天检查一次更新 + dedupingInterval: 60 * 60 * 1000, // 1小时内不重复检查, + isPaused: () => !(verge?.auto_check_update ?? true), // 根据 auto_check_update 设置决定是否启用 + }, + ); + + // 导航到设置页面 + const goToSettings = () => { + navigate("/settings"); + }; + + // 切换自启动状态 + const toggleAutoLaunch = async () => { + try { + if (!verge) return; + // 将当前的启动状态取反 + await patchVerge({ enable_auto_launch: !verge.enable_auto_launch }); + } catch (err) { + console.error("切换开机自启动状态失败:", err); + } + }; + + // 安装系统服务 + const onInstallService = useLockFn(async () => { + try { + Notice.info(t("Installing Service..."), 1000); + await installService(); + Notice.success(t("Service Installed Successfully"), 2000); + // 重新获取运行模式 + await mutateRunningMode(); + } catch (err: any) { + Notice.error(err.message || err.toString(), 3000); + } + }); + + // 点击运行模式 + const handleRunningModeClick = () => { + if (runningMode === "sidecar") { + onInstallService(); + } + }; + + // 检查更新 + const onCheckUpdate = async () => { + try { + const info = await checkUpdate(); + if (!info?.available) { + Notice.success(t("Currently on the Latest Version")); + } else { + Notice.info(t("Update Available"), 2000); + goToSettings(); // 跳转到设置页面查看更新 + } + } catch (err: any) { + Notice.error(err.message || err.toString()); + } + }; + + return ( + } + iconColor="error" + action={ + + + + } + > + {verge && ( + + + + {t("OS Info")} + + + {osInfo} + + + + + + {t("Auto Launch")} + + + + + + + {t("Running Mode")} + + + {runningMode === "service" + ? t("Service Mode") + : t("Sidecar Mode")} + + + + + + {t("Last Check Update")} + + + {lastCheckUpdate} + + + + + + {t("Verge Version")} + + + v{appVersion} + + + + )} + + ); +}; diff --git a/src/components/home/test-card.tsx b/src/components/home/test-card.tsx new file mode 100644 index 00000000..cab3641b --- /dev/null +++ b/src/components/home/test-card.tsx @@ -0,0 +1,172 @@ +import { useEffect, useRef } from "react"; +import { useVerge } from "@/hooks/use-verge"; +import { Box, IconButton, Tooltip, alpha, styled } from "@mui/material"; +import Grid from "@mui/material/Grid2"; +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from "@dnd-kit/core"; +import { SortableContext } from "@dnd-kit/sortable"; + +import { useTranslation } from "react-i18next"; +import { TestViewer, TestViewerRef } from "@/components/test/test-viewer"; +import { TestItem } from "@/components/test/test-item"; +import { emit } from "@tauri-apps/api/event"; +import { nanoid } from "nanoid"; +import { Add, NetworkCheck } from "@mui/icons-material"; +import { EnhancedCard } from "./enhanced-card"; + +// test icons +import apple from "@/assets/image/test/apple.svg?raw"; +import github from "@/assets/image/test/github.svg?raw"; +import google from "@/assets/image/test/google.svg?raw"; +import youtube from "@/assets/image/test/youtube.svg?raw"; + +// 自定义滚动条样式 +const ScrollBox = styled(Box)(({ theme }) => ({ + maxHeight: "180px", + overflowY: "auto", + overflowX: "hidden", + "&::-webkit-scrollbar": { + width: "6px", + }, + "&::-webkit-scrollbar-thumb": { + backgroundColor: alpha(theme.palette.text.primary, 0.2), + borderRadius: "3px", + }, +})); + +export const TestCard = () => { + const { t } = useTranslation(); + const sensors = useSensors(useSensor(PointerSensor)); + const { verge, mutateVerge, patchVerge } = useVerge(); + + // test list + const testList = verge?.test_list ?? [ + { + uid: nanoid(), + name: "Apple", + url: "https://www.apple.com", + icon: apple, + }, + { + uid: nanoid(), + name: "GitHub", + url: "https://www.github.com", + icon: github, + }, + { + uid: nanoid(), + name: "Google", + url: "https://www.google.com", + icon: google, + }, + { + uid: nanoid(), + name: "Youtube", + url: "https://www.youtube.com", + icon: youtube, + }, + ]; + + 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 onDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + if (over && 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) { + const newList = [...testList]; + const [removed] = newList.splice(old_index, 1); + newList.splice(new_index, 0, removed); + + 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 ( + } + action={ + + + emit("verge://test-all")}> + + + + + viewerRef.current?.create()} + > + + + + + } + > + + + + x.uid)}> + {testList.map((item) => ( + + viewerRef.current?.edit(item)} + onDelete={onDeleteTestListItem} + /> + + ))} + + + + + + + + ); +}; diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index 4cf7101f..05936f0e 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -81,7 +81,7 @@ export const ProfileItem = (props: Props) => { const expire = parseExpire(extra?.expire); const progress = Math.min( Math.round(((download + upload) * 100) / (total + 0.01)) + 1, - 100 + 100, ); const loading = loadingCache[itemData.uid] ?? false; @@ -202,11 +202,12 @@ export const ProfileItem = (props: Props) => { try { await updateProfile(itemData.uid, option); + Notice.success(t("Update subscription successfully")); mutate("getProfiles"); } catch (err: any) { const errmsg = err?.message || err.toString(); Notice.error( - errmsg.replace(/error sending request for url (\S+?): /, "") + errmsg.replace(/error sending request for url (\S+?): /, ""), ); } finally { setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false })); diff --git a/src/components/proxy/proxy-head.tsx b/src/components/proxy/proxy-head.tsx index 1b3ab170..205f729d 100644 --- a/src/components/proxy/proxy-head.tsx +++ b/src/components/proxy/proxy-head.tsx @@ -57,7 +57,7 @@ export const ProxyHead = (props: Props) => { diff --git a/src/components/shared/ProxyControlSwitches.tsx b/src/components/shared/ProxyControlSwitches.tsx new file mode 100644 index 00000000..86efcf4e --- /dev/null +++ b/src/components/shared/ProxyControlSwitches.tsx @@ -0,0 +1,296 @@ +import { useRef, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import useSWR, { mutate } from "swr"; +import { + SettingsRounded, + PlayCircleOutlineRounded, + PauseCircleOutlineRounded, + BuildRounded, +} from "@mui/icons-material"; +import { + Box, + Button, + Tooltip, + Typography, + alpha, + useTheme, +} from "@mui/material"; +import { DialogRef, Notice, Switch } from "@/components/base"; +import { TooltipIcon } from "@/components/base/base-tooltip-icon"; +import { GuardState } from "@/components/setting/mods/guard-state"; +import { SysproxyViewer } from "@/components/setting/mods/sysproxy-viewer"; +import { TunViewer } from "@/components/setting/mods/tun-viewer"; +import { useVerge } from "@/hooks/use-verge"; +import { + getSystemProxy, + getAutotemProxy, + getRunningMode, + installService, +} from "@/services/cmds"; +import { useLockFn } from "ahooks"; +import { SettingItem } from "@/components/setting/mods/setting-comp"; + +interface ProxySwitchProps { + label?: string; + onError?: (err: Error) => void; +} + +/** + * 可复用的代理控制开关组件 + * 包含 Tun Mode 和 System Proxy 的开关功能 + */ +const ProxyControlSwitches = ({ label, onError }: ProxySwitchProps) => { + const { t } = useTranslation(); + const { verge, mutateVerge, patchVerge } = useVerge(); + const theme = useTheme(); + + const { data: sysproxy } = useSWR("getSystemProxy", getSystemProxy); + const { data: autoproxy } = useSWR("getAutotemProxy", getAutotemProxy); + const { data: runningMode, mutate: mutateRunningMode } = useSWR( + "getRunningMode", + getRunningMode, + ); + + // 是否以sidecar模式运行 + const isSidecarMode = runningMode === "sidecar"; + + const sysproxyRef = useRef(null); + const tunRef = useRef(null); + + const { enable_tun_mode, enable_system_proxy, proxy_auto_config } = + verge ?? {}; + + // 确定当前显示哪个开关 + const isSystemProxyMode = label === t("System Proxy") || !label; + const isTunMode = label === t("Tun Mode"); + + const onSwitchFormat = (_e: any, value: boolean) => value; + const onChangeData = (patch: Partial) => { + mutateVerge({ ...verge, ...patch }, false); + }; + + const updateProxyStatus = async () => { + // 等待一小段时间让系统代理状态变化 + await new Promise((resolve) => setTimeout(resolve, 100)); + await mutate("getSystemProxy"); + await mutate("getAutotemProxy"); + }; + + // 安装系统服务 + const onInstallService = useLockFn(async () => { + try { + Notice.info(t("Installing Service..."), 1000); + await installService(); + Notice.success(t("Service Installed Successfully"), 2000); + // 重新获取运行模式 + await mutateRunningMode(); + } catch (err: any) { + Notice.error(err.message || err.toString(), 3000); + } + }); + + return ( + + {label && ( + + {label} + + )} + + {/* 仅显示当前选中的开关 */} + {isSystemProxyMode && ( + + + {proxy_auto_config ? ( + autoproxy?.enable ? ( + + ) : ( + + ) + ) : sysproxy?.enable ? ( + + ) : ( + + )} + + + + {t("System Proxy")} + + {/* + {sysproxy?.enable + ? t("Proxy is active") + : t("Enable this for most users") + } + */} + + + + + + sysproxyRef.current?.open()} + > + + + + + onChangeData({ enable_system_proxy: e })} + onGuard={async (e) => { + await patchVerge({ enable_system_proxy: e }); + await updateProxyStatus(); + }} + > + + + + + )} + + {isTunMode && ( + + + {enable_tun_mode ? ( + + ) : ( + + )} + + + + {t("Tun Mode")} + + {/* + {isSidecarMode + ? t("TUN requires Service Mode") + : t("For special applications") + } + */} + + + + + {isSidecarMode && ( + + + + )} + + + tunRef.current?.open()} + > + + + + + { + // 当在sidecar模式下禁用切换 + if (isSidecarMode) return; + onChangeData({ enable_tun_mode: e }); + }} + onGuard={(e) => { + // 当在sidecar模式下禁用切换 + if (isSidecarMode) { + Notice.error(t("TUN requires Service Mode"), 2000); + return Promise.reject( + new Error(t("TUN requires Service Mode")), + ); + } + return patchVerge({ enable_tun_mode: e }); + }} + > + + + + + )} + + {/* 引用对话框组件 */} + + + + ); +}; + +export default ProxyControlSwitches; diff --git a/src/components/test/test-box.tsx b/src/components/test/test-box.tsx index 5dc2fa7a..43ea8242 100644 --- a/src/components/test/test-box.tsx +++ b/src/components/test/test-box.tsx @@ -4,7 +4,8 @@ export const TestBox = styled(Box)(({ theme, "aria-selected": selected }) => { const { mode, primary, text } = theme.palette; const key = `${mode}-${!!selected}`; - const backgroundColor = mode === "light" ? "#ffffff" : "#282A36"; + const backgroundColor = + mode === "light" ? alpha(primary.main, 0.05) : alpha(primary.main, 0.08); const color = { "light-true": text.secondary, @@ -27,11 +28,17 @@ export const TestBox = styled(Box)(({ theme, "aria-selected": selected }) => { cursor: "pointer", textAlign: "left", borderRadius: 8, - boxShadow: theme.shadows[2], + boxShadow: theme.shadows[1], padding: "8px 16px", boxSizing: "border-box", backgroundColor, color, "& h2": { color: h2color }, + transition: "background-color 0.3s, box-shadow 0.3s", + "&:hover": { + backgroundColor: + mode === "light" ? alpha(primary.main, 0.1) : alpha(primary.main, 0.15), + boxShadow: theme.shadows[2], + }, }; }); diff --git a/src/components/test/test-item.tsx b/src/components/test/test-item.tsx index dfb75315..2a1fc8ee 100644 --- a/src/components/test/test-item.tsx +++ b/src/components/test/test-item.tsx @@ -3,15 +3,7 @@ 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 { Box, Divider, MenuItem, Menu, styled, alpha } from "@mui/material"; import { BaseLoading } from "@/components/base"; import { LanguageRounded } from "@mui/icons-material"; import { Notice } from "@/components/base"; @@ -149,11 +141,7 @@ export const TestItem = (props: Props) => { )} - - - {name} - - + {name} { + // 获取代理信息 + const { data: proxiesData, mutate: mutateProxies } = useSWR( + "getProxies", + getProxies, + { + refreshInterval: 3000, + revalidateOnFocus: false, + revalidateOnReconnect: true, + }, + ); + + // 获取当前Clash配置(包含模式信息) + const { data: clashConfig } = useSWR("getClashConfig", getClashConfig); + + // 获取当前模式 + const currentMode = clashConfig?.mode?.toLowerCase() || "rule"; + + // 获取当前代理节点信息 + const currentProxyInfo = useMemo(() => { + if (!proxiesData) return { currentProxy: null, primaryGroupName: null }; + + const { global, groups, records } = proxiesData; + + // 默认信息 + let primaryGroupName = "GLOBAL"; + let currentName = global?.now; + + // 在规则模式下,寻找主要代理组(通常是第一个或者名字包含特定关键词的组) + if (currentMode === "rule" && groups.length > 0) { + // 查找主要的代理组(优先级:包含关键词 > 第一个非GLOBAL组) + const primaryKeywords = [ + "auto", + "select", + "proxy", + "节点选择", + "自动选择", + ]; + const primaryGroup = + groups.find((group) => + primaryKeywords.some((keyword) => + group.name.toLowerCase().includes(keyword.toLowerCase()), + ), + ) || groups.filter((g) => g.name !== "GLOBAL")[0]; + + if (primaryGroup) { + primaryGroupName = primaryGroup.name; + currentName = primaryGroup.now; + } + } + + // 如果找不到当前节点,返回null + if (!currentName) return { currentProxy: null, primaryGroupName }; + + // 获取完整的节点信息 + const currentProxy = records[currentName] || { + name: currentName, + type: "Unknown", + udp: false, + xudp: false, + tfo: false, + mptcp: false, + smux: false, + history: [], + }; + + return { currentProxy, primaryGroupName }; + }, [proxiesData, currentMode]); + + return { + currentProxy: currentProxyInfo.currentProxy, + primaryGroupName: currentProxyInfo.primaryGroupName, + mode: currentMode, + refreshProxy: mutateProxies, + }; +}; diff --git a/src/locales/ar.json b/src/locales/ar.json index 0cff6cfd..581dfec4 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -32,7 +32,7 @@ "global": "عالمي", "direct": "مباشر", "script": "سكريبت", - "Location": "الموقع", + "locate": "الموقع", "Delay check": "فحص التأخير", "Sort by default": "الترتيب الافتراضي", "Sort by delay": "الترتيب حسب التأخير", diff --git a/src/locales/en.json b/src/locales/en.json index 0426a22a..7d351532 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1,5 +1,5 @@ { - "millis": "millis", + "millis": "ms", "seconds": "seconds", "mins": "mins", "Back": "Back", @@ -16,12 +16,12 @@ "Delete": "Delete", "Enable": "Enable", "Disable": "Disable", + "Label-Home": "Home", "Label-Proxies": "Proxies", "Label-Profiles": "Profiles", "Label-Connections": "Connections", "Label-Rules": "Rules", "Label-Logs": "Logs", - "Label-Test": "Test", "Label-Settings": "Settings", "Proxies": "Proxies", "Proxy Groups": "Proxy Groups", @@ -32,7 +32,7 @@ "global": "global", "direct": "direct", "script": "script", - "Location": "Location", + "locate": "locate", "Delay check": "Delay check", "Sort by default": "Sort by default", "Sort by delay": "Sort by delay", @@ -165,7 +165,8 @@ "Table View": "Table View", "List View": "List View", "Close All": "Close All", - "Default": "Default", + "Upload": "Upload", + "Download": "Download", "Download Speed": "Download Speed", "Upload Speed": "Upload Speed", "Host": "Host", @@ -173,6 +174,7 @@ "Uploaded": "Uploaded", "DL Speed": "DL Speed", "UL Speed": "UL Speed", + "Active Connections": "Active Connections", "Chains": "Chains", "Rule": "Rule", "Process": "Process", @@ -194,11 +196,18 @@ "Test URL": "Test URL", "Settings": "Settings", "System Setting": "System Setting", - "Tun Mode": "Tun (Virtual NIC) Mode", - "TUN requires Service Mode": "TUN mode requires service", + "Tun Mode": "Tun Mode", + "TUN requires Service Mode": "TUN mode requires install service", "Install Service": "Install Service", "Reset to Default": "Reset to Default", "Tun Mode Info": "Tun (Virtual NIC) mode: Captures all system traffic, when enabled, there is no need to enable system proxy.", + "System Proxy Enabled": "System proxy is enabled, your applications will access the network through the proxy", + "System Proxy Disabled": "System proxy is disabled, it is recommended for most users to turn on this option", + "TUN Mode Service Required": "TUN mode requires service mode, please install the service first", + "TUN Mode Intercept Info": "TUN mode can take over all application traffic, suitable for special applications", + "Rule Mode Description": "Routes traffic according to preset rules, provides flexible proxy strategies", + "Global Mode Description": "All traffic goes through proxy servers, suitable for scenarios requiring global internet access", + "Direct Mode Description": "All traffic doesn't go through proxy nodes, but is forwarded by Clash kernel to target servers, suitable for specific scenarios requiring kernel traffic distribution", "Stack": "Tun Stack", "System and Mixed Can Only be Used in Service Mode": "System and Mixed Can Only be Used in Service Mode", "Device": "Device Name", @@ -307,7 +316,7 @@ "CSS Injection": "CSS Injection", "Layout Setting": "Layout Setting", "Traffic Graph": "Traffic Graph", - "Memory Usage": "Memory Usage", + "Memory Usage": "Core Usage", "Memory Cleanup": "Tap to clean up memory", "Proxy Group Icon": "Proxy Group Icon", "Nav Icon": "Nav Icon", @@ -380,7 +389,6 @@ "Permissions Granted Successfully for _clash Core": "Permissions Granted Successfully for {{core}} Core", "Core Version Updated": "Core Version Updated", "Clash Core Restarted": "Clash Core Restarted", - "Switched to _clash Core": "Switched to {{core}} Core", "GeoData Updated": "GeoData Updated", "Currently on the Latest Version": "Currently on the Latest Version", "Import Subscription Successful": "Import subscription successful", @@ -412,19 +420,12 @@ "Help": "Help", "About": "About", "Theme": "Theme", - "TUN Mode": "TUN Mode", "Main Window": "Main Window", "Group Icon": "Group Icon", "Menu Icon": "Menu Icon", - "System Proxy Bypass": "System Proxy Bypass", "PAC File": "PAC File", "Web UI": "Web UI", "Hotkeys": "Hotkeys", - "Auto Close Connection": "Auto Close Connection", - "Enable Built-in Enhanced": "Enable Built-in Enhanced", - "Proxy Layout Column": "Proxy Layout Column", - "Test List": "Test List", - "Enable Random Port": "Enable Random Port", "Verge Mixed Port": "Verge Mixed Port", "Verge Socks Port": "Verge Socks Port", "Verge Redir Port": "Verge Redir Port", @@ -434,15 +435,16 @@ "WebDAV URL": "WebDAV URL", "WebDAV Username": "WebDAV Username", "WebDAV Password": "WebDAV Password", + "Dashboard": "Dashboard", + "Restart App": "Restart App", + "Restart Clash Core": "Restart Clash Core", + "TUN Mode": "TUN Mode", "Copy Env": "Copy Env", "Conf Dir": "Conf Dir", "Core Dir": "Core Dir", "Logs Dir": "Logs Dir", "Open Dir": "Open Dir", - "Restart Clash Core": "Restart Clash Core", - "Restart App": "Restart App", "More": "More", - "Dashboard": "Dashboard", "Rule Mode": "Rule Mode", "Global Mode": "Global Mode", "Direct Mode": "Direct Mode", @@ -514,5 +516,44 @@ "Fallback Domain": "Fallback Domain", "Domains using fallback servers": "Domains using fallback servers, comma separated", "Enable Alpha Channel": "Enable Alpha Channel", - "Alpha versions may contain experimental features and bugs": "Alpha versions may contain experimental features and bugs" + "Alpha versions may contain experimental features and bugs": "Alpha versions may contain experimental features and bugs", + "Home Settings": "Home Settings", + "Profile Card": "Profile Card", + "Current Proxy Card": "Current Proxy Card", + "Network Settings Card": "Network Settings Card", + "Proxy Mode Card": "Proxy Mode Card", + "Clash Mode Card": "Clash Mode Card", + "Traffic Stats Card": "Traffic Stats Card", + "Clash Info Cards": "Clash Info Cards", + "System Info Cards": "System Info Cards", + "Website Tests Card": "Website Tests Card", + "Traffic Stats": "Traffic Stats", + "Website Tests": "Website Tests", + "Clash Info": "Clash Info", + "Core Version": "Core Version", + "System Proxy Address": "System Proxy Address", + "Uptime": "Uptime", + "Rules Count": "Rules Count", + "System Info": "System Info", + "OS Info": "OS Info", + "Running Mode": "Running Mode", + "Sidecar Mode": "User Mode", + "Last Check Update": "Last Check Update", + "Click to import subscription": "Click to import subscription", + "Update subscription successfully": "Update subscription successfully", + "Current Node": "Current Node", + "No active proxy node": "No active proxy node", + "Network Settings": "Network Settings", + "Proxy Mode": "Proxy Mode", + "Group": "Group", + "Proxy": "Proxy", + "IP Information Card": "IP Information Card", + "IP Information": "IP Information", + "Failed to get IP info": "Failed to get IP info", + "ISP": "ISP", + "ASN": "ASN", + "ORG": "ORG", + "Location": "Location", + "Timezone": "Timezone", + "Auto refresh": "Auto refresh" } diff --git a/src/locales/fa.json b/src/locales/fa.json index 77a8db2f..efc2fa1e 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -32,7 +32,7 @@ "global": "جهانی", "direct": "مستقیم", "script": "اسکریپت", - "Location": "موقعیت", + "locate": "موقعیت", "Delay check": "بررسی تأخیر", "Sort by default": "مرتب‌سازی بر اساس پیش‌فرض", "Sort by delay": "مرتب‌سازی بر اساس تأخیر", diff --git a/src/locales/id.json b/src/locales/id.json index 1e110708..4b0400a3 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -64,7 +64,7 @@ "global": "global", "direct": "langsung", "script": "skrip", - "Location": "Lokasi", + "locate": "Lokasi", "Delay check": "Periksa Keterlambatan", "Sort by default": "Urutkan secara default", "Sort by delay": "Urutkan berdasarkan keterlambatan", diff --git a/src/locales/ru.json b/src/locales/ru.json index 99cdb77b..647773d5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -32,7 +32,7 @@ "global": "глобальный", "direct": "прямой", "script": "скриптовый", - "Location": "Местоположение", + "locate": "Местоположение", "Delay check": "Проверка задержки", "Sort by default": "Сортировать по умолчанию", "Sort by delay": "Сортировать по задержке", diff --git a/src/locales/tt.json b/src/locales/tt.json index 5c530750..07d0352b 100644 --- a/src/locales/tt.json +++ b/src/locales/tt.json @@ -32,7 +32,7 @@ "global": "глобаль", "direct": "туры", "script": "скриптлы", - "Location": "Урын", + "locate": "Урын", "Delay check": "Задержканы тикшерү", "Sort by default": "Башлангыч итеп сортлау", "Sort by delay": "Задержка буенча сортлау", diff --git a/src/locales/zh.json b/src/locales/zh.json index d7aa665c..511ddaff 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -16,12 +16,12 @@ "Delete": "删除", "Enable": "启用", "Disable": "禁用", + "Label-Home": "首 页", "Label-Proxies": "代 理", "Label-Profiles": "订 阅", "Label-Connections": "连 接", "Label-Rules": "规 则", "Label-Logs": "日 志", - "Label-Test": "测 试", "Label-Settings": "设 置", "Proxies": "代理", "Proxy Groups": "代理组", @@ -32,7 +32,7 @@ "global": "全局", "direct": "直连", "script": "脚本", - "Location": "当前节点", + "locate": "当前节点", "Delay check": "延迟测试", "Sort by default": "默认排序", "Sort by delay": "按延迟排序", @@ -165,7 +165,8 @@ "Table View": "表格视图", "List View": "列表视图", "Close All": "关闭全部", - "Default": "默认", + "Upload": "上传", + "Download": "下载", "Download Speed": "下载速度", "Upload Speed": "上传速度", "Host": "主机", @@ -173,6 +174,7 @@ "Uploaded": "上传量", "DL Speed": "下载速度", "UL Speed": "上传速度", + "Active Connections": "活跃连接", "Chains": "链路", "Rule": "规则", "Process": "进程", @@ -194,11 +196,18 @@ "Test URL": "测试地址", "Settings": "设置", "System Setting": "系统设置", - "Tun Mode": "TUN(虚拟网卡)模式", - "TUN requires Service Mode": "TUN 模式需要服务", + "Tun Mode": "虚拟网卡模式", + "TUN requires Service Mode": "TUN 模式需要安装服务", "Install Service": "安装服务", "Reset to Default": "重置为默认值", "Tun Mode Info": "TUN(虚拟网卡)模式接管系统所有流量,启用时无须打开系统代理", + "System Proxy Enabled": "系统代理已启用,您的应用将通过代理访问网络", + "System Proxy Disabled": "系统代理已关闭,建议大多数用户打开此选项", + "TUN Mode Service Required": "TUN模式需要服务模式,请先安装服务", + "TUN Mode Intercept Info": "TUN模式可以接管所有应用流量,适用于特殊应用", + "Rule Mode Description": "基于预设规则智能判断流量走向,提供灵活的代理策略", + "Global Mode Description": "所有流量均通过代理服务器,适用于需要全局科学上网的场景", + "Direct Mode Description": "所有流量不经过代理节点,但经过Clash内核转发连接目标服务器,适用于需要通过内核进行分流的特定场景", "Stack": "TUN 模式堆栈", "System and Mixed Can Only be Used in Service Mode": "System 和 Mixed 只能在服务模式下使用", "Device": "TUN 网卡名称", @@ -279,7 +288,8 @@ "Open UWP tool": "UWP 工具", "Open UWP tool Info": "Windows 8 开始限制 UWP 应用(如微软商店)直接访问本地主机的网络服务,使用此工具可绕过该限制", "Update GeoData": "更新 GeoData", - "Verge Setting": "Verge 设置", + "Verge Basic Setting": "Verge 基础设置", + "Verge Advanced Setting": "Verge 高级设置", "Language": "语言设置", "Theme Mode": "主题模式", "theme.light": "浅色", @@ -306,7 +316,7 @@ "CSS Injection": "CSS 注入", "Layout Setting": "界面设置", "Traffic Graph": "流量图显", - "Memory Usage": "内存占用", + "Memory Usage": "内核占用", "Memory Cleanup": "点击清理内存", "Proxy Group Icon": "代理组图标", "Nav Icon": "导航栏图标", @@ -351,6 +361,8 @@ "Portable Updater Error": "便携版不支持应用内更新,请手动下载替换", "Break Change Update Error": "此版本为重大更新,不支持应用内更新,请卸载后手动下载安装", "Open Dev Tools": "开发者工具", + "Export Diagnostic Info": "导出诊断信息", + "Export Diagnostic Info For Issue Reporting": "导出诊断信息用于问题报告", "Exit": "退出", "Verge Version": "Verge 版本", "ReadOnly": "只读", @@ -462,8 +474,6 @@ "Validate Merge File": "验证覆写文件", "Validation Success": "验证成功", "Validation Failed": "验证失败", - "Verge Basic Setting": "Verge 基础设置", - "Verge Advanced Setting": "Verge 高级设置", "Service Administrator Prompt": "Clash Verge 需要使用管理员权限来重新安装系统服务", "DNS Settings": "DNS 设置", "DNS Overwrite": "DNS 覆写", @@ -506,5 +516,44 @@ "Fallback Domain": "回退域名", "Domains using fallback servers": "使用回退服务器的域名,用逗号分隔", "Enable Alpha Channel": "启用 Alpha 通道", - "Alpha versions may contain experimental features and bugs": "Alpha 版本可能包含实验性功能和已知问题" + "Alpha versions may contain experimental features and bugs": "Alpha 版本可能包含实验性功能和已知问题", + "Home Settings": "首页设置", + "Profile Card": "订阅卡", + "Current Proxy Card": "当前代理卡", + "Network Settings Card": "网络设置卡", + "Proxy Mode Card": "代理模式卡", + "Clash Mode Card": "Clash 模式卡", + "Traffic Stats Card": "流量统计卡", + "Clash Info Cards": "Clash 信息卡", + "System Info Cards": "系统信息卡", + "Website Tests Card": "网站测试卡", + "Traffic Stats": "流量统计", + "Website Tests": "网站测试", + "Clash Info": "Clash 信息", + "Core Version": "内核版本", + "System Proxy Address": "系统代理地址", + "Uptime": "运行时间", + "Rules Count": "规则数量", + "System Info": "系统信息", + "OS Info": "操作系统信息", + "Running Mode": "运行模式", + "Sidecar Mode": "用户模式", + "Last Check Update": "最后检查更新", + "Click to import subscription": "点击导入订阅", + "Update subscription successfully": "订阅更新成功", + "Current Node": "当前节点", + "No active proxy node": "暂无激活的代理节点", + "Network Settings": "网络设置", + "Proxy Mode": "代理模式", + "Group": "代理组", + "Proxy": "节点", + "IP Information Card": "IP信息卡", + "IP Information": "IP信息", + "Failed to get IP info": "获取IP信息失败", + "ISP": "服务商", + "ASN": "自治域", + "ORG": "组织", + "Location": "位置", + "Timezone": "时区", + "Auto refresh": "自动刷新" } diff --git a/src/pages/_routers.tsx b/src/pages/_routers.tsx index 4c6e81dc..2c72187b 100644 --- a/src/pages/_routers.tsx +++ b/src/pages/_routers.tsx @@ -1,10 +1,10 @@ import LogsPage from "./logs"; import ProxiesPage from "./proxies"; -import TestPage from "./test"; import ProfilesPage from "./profiles"; import SettingsPage from "./settings"; import ConnectionsPage from "./connections"; import RulesPage from "./rules"; +import HomePage from "./home"; import { BaseErrorBoundary } from "@/components/base"; import ProxiesSvg from "@/assets/image/itemicon/proxies.svg?react"; @@ -12,7 +12,6 @@ import ProfilesSvg from "@/assets/image/itemicon/profiles.svg?react"; import ConnectionsSvg from "@/assets/image/itemicon/connections.svg?react"; import RulesSvg from "@/assets/image/itemicon/rules.svg?react"; import LogsSvg from "@/assets/image/itemicon/logs.svg?react"; -import TestSvg from "@/assets/image/itemicon/test.svg?react"; import SettingsSvg from "@/assets/image/itemicon/settings.svg?react"; import WifiRoundedIcon from "@mui/icons-material/WifiRounded"; @@ -22,8 +21,15 @@ import ForkRightRoundedIcon from "@mui/icons-material/ForkRightRounded"; import SubjectRoundedIcon from "@mui/icons-material/SubjectRounded"; import WifiTetheringRoundedIcon from "@mui/icons-material/WifiTetheringRounded"; import SettingsRoundedIcon from "@mui/icons-material/SettingsRounded"; +import HomeRoundedIcon from "@mui/icons-material/HomeRounded"; export const routers = [ + { + label: "Label-Home", + path: "/home", + icon: [], + element: , + }, { label: "Label-Proxies", path: "/", @@ -54,12 +60,6 @@ export const routers = [ icon: [, ], element: , }, - { - label: "Label-Test", - path: "/test", - icon: [, ], - element: , - }, { label: "Label-Settings", path: "/settings", diff --git a/src/pages/home.tsx b/src/pages/home.tsx new file mode 100644 index 00000000..56f242e7 --- /dev/null +++ b/src/pages/home.tsx @@ -0,0 +1,381 @@ +import { useTranslation } from "react-i18next"; +import { + Box, + Button, + IconButton, + useTheme, + keyframes, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + FormGroup, + FormControlLabel, + Checkbox, + Tooltip, +} from "@mui/material"; +import Grid from "@mui/material/Grid2"; +import { useVerge } from "@/hooks/use-verge"; +import { useProfiles } from "@/hooks/use-profiles"; +import { + RouterOutlined, + SettingsOutlined, + DnsOutlined, + SpeedOutlined, + HelpOutlineRounded, +} from "@mui/icons-material"; +import { useNavigate } from "react-router-dom"; +import { ProxyTunCard } from "@/components/home/proxy-tun-card"; +import { ClashModeCard } from "@/components/home/clash-mode-card"; +import { EnhancedTrafficStats } from "@/components/home/enhanced-traffic-stats"; +import { useState } from "react"; +import { HomeProfileCard } from "@/components/home/home-profile-card"; +import { EnhancedCard } from "@/components/home/enhanced-card"; +import { CurrentProxyCard } from "@/components/home/current-proxy-card"; +import { BasePage } from "@/components/base"; +import { ClashInfoCard } from "@/components/home/clash-info-card"; +import { SystemInfoCard } from "@/components/home/system-info-card"; +import { useLockFn } from "ahooks"; +import { openWebUrl } from "@/services/cmds"; +import { TestCard } from "@/components/home/test-card"; +import { IpInfoCard } from "@/components/home/ip-info-card"; + +// 定义旋转动画 +const round = keyframes` + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +`; + +// 辅助函数解析URL和过期时间 +function parseUrl(url?: string) { + if (!url) return "-"; + if (url.startsWith("http")) return new URL(url).host; + return "local"; +} + +// 定义首页卡片设置接口 +interface HomeCardsSettings { + profile: boolean; + proxy: boolean; + network: boolean; + mode: boolean; + traffic: boolean; + info: boolean; + clashinfo: boolean; + systeminfo: boolean; + test: boolean; + ip: boolean; + [key: string]: boolean; +} + +// 首页设置对话框组件接口 +interface HomeSettingsDialogProps { + open: boolean; + onClose: () => void; + homeCards: HomeCardsSettings; + onSave: (cards: HomeCardsSettings) => void; +} + +// 首页设置对话框组件 +const HomeSettingsDialog = ({ + open, + onClose, + homeCards, + onSave, +}: HomeSettingsDialogProps) => { + const { t } = useTranslation(); + const [cards, setCards] = useState(homeCards); + const { patchVerge } = useVerge(); + + const handleToggle = (key: string) => { + setCards((prev: HomeCardsSettings) => ({ + ...prev, + [key]: !prev[key], + })); + }; + + const handleSave = async () => { + await patchVerge({ home_cards: cards }); + onSave(cards); + onClose(); + }; + + return ( + + {t("Home Settings")} + + + handleToggle("profile")} + /> + } + label={t("Profile Card")} + /> + handleToggle("proxy")} + /> + } + label={t("Current Proxy Card")} + /> + handleToggle("network")} + /> + } + label={t("Network Settings Card")} + /> + handleToggle("mode")} + /> + } + label={t("Proxy Mode Card")} + /> + handleToggle("traffic")} + /> + } + label={t("Traffic Stats Card")} + /> + handleToggle("test")} + /> + } + label={t("Website Tests Card")} + /> + handleToggle("ip")} + /> + } + label={t("IP Information Card")} + /> + handleToggle("clashinfo")} + /> + } + label={t("Clash Info Cards")} + /> + handleToggle("systeminfo")} + /> + } + label={t("System Info Cards")} + /> + + + + + + + + ); +}; + +const HomePage = () => { + const { t } = useTranslation(); + const { verge } = useVerge(); + const { current } = useProfiles(); + const navigate = useNavigate(); + const theme = useTheme(); + + // 设置弹窗的状态 + const [settingsOpen, setSettingsOpen] = useState(false); + // 卡片显示状态 + const [homeCards, setHomeCards] = useState( + (verge?.home_cards as HomeCardsSettings) || { + profile: true, + proxy: true, + network: true, + mode: true, + traffic: true, + clashinfo: true, + systeminfo: true, + test: true, + ip: true, + }, + ); + + // 导航到订阅页面 + const goToProfiles = () => { + navigate("/profile"); + }; + + // 导航到代理页面 + const goToProxies = () => { + navigate("/"); + }; + + // 导航到设置页面 + const goToSettings = () => { + navigate("/settings"); + }; + + // 文档链接函数 + const toGithubDoc = useLockFn(() => { + return openWebUrl("https://clash-verge-rev.github.io/index.html"); + }); + + // 新增:打开设置弹窗 + const openSettings = () => { + setSettingsOpen(true); + }; + + // 新增:保存设置 + const handleSaveSettings = (newCards: HomeCardsSettings) => { + setHomeCards(newCards); + }; + + return ( + + + + + + + + + + + + + } + > + + {/* 订阅和当前节点部分 */} + {homeCards.profile && ( + + + + )} + + {homeCards.proxy && ( + + + + )} + + {/* 代理和网络设置区域 */} + {homeCards.network && ( + + + + )} + + {homeCards.mode && ( + + + + )} + + {/* 增强的流量统计区域 */} + {homeCards.traffic && ( + + } + iconColor="secondary" + minHeight={280} + > + + + + )} + {/* 测试网站部分 */} + {homeCards.test && ( + + + + )} + {/* IP信息卡片 */} + {homeCards.ip && ( + + + + )} + {/* Clash信息 */} + {homeCards.clashinfo && ( + + + + )} + {/* 系统信息 */} + {homeCards.systeminfo && ( + + + + )} + + + {/* 首页设置弹窗 */} + setSettingsOpen(false)} + homeCards={homeCards} + onSave={handleSaveSettings} + /> + + ); +}; + +// 增强版网络设置卡片组件 +const NetworkSettingsCard = () => { + const { t } = useTranslation(); + return ( + } + iconColor="primary" + action={null} + > + + + ); +}; + +// 增强版 Clash 模式卡片组件 +const ClashModeEnhancedCard = () => { + const { t } = useTranslation(); + return ( + } + iconColor="info" + action={null} + > + + + ); +}; + +export default HomePage; diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index a8d712df..47cdb91d 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -1,11 +1,5 @@ -import { - Box, - ButtonGroup, - Grid, - IconButton, - Select, - MenuItem, -} from "@mui/material"; +import { Box, ButtonGroup, IconButton, Select, MenuItem } from "@mui/material"; +import Grid from "@mui/material/Grid2"; import { useLockFn } from "ahooks"; import { useTranslation } from "react-i18next"; import { BasePage, Notice } from "@/components/base"; @@ -72,8 +66,8 @@ const SettingPage = () => { } > - - + + { - + { // Close all connections export const closeAllConnections = async () => { const instance = await getAxios(); - await instance.delete(`/connections`); + await instance.delete("/connections"); }; // Get Group Proxy Delays @@ -313,3 +313,22 @@ export const gc = async () => { console.error(`Error gcing: ${error}`); } }; + +// Get current IP and geolocation information +export const getIpInfo = async () => { + // 使用axios直接请求IP.sb的API,不通过clash代理 + const response = await axios.get("https://api.ip.sb/geoip"); + return response.data as { + ip: string; + country_code: string; + country: string; + region: string; + city: string; + organization: string; + asn: number; + asn_organization: string; + longitude: number; + latitude: number; + timezone: string; + }; +}; diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 505b82db..c9592944 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -225,6 +225,10 @@ export async function exportDiagnosticInfo() { return invoke("export_diagnostic_info"); } +export async function getSystemInfo() { + return invoke("get_system_info"); +} + export async function copyIconFile( path: string, name: "common" | "sysproxy" | "tun", @@ -301,6 +305,11 @@ export const getRunningMode = async () => { return invoke("get_running_mode"); }; +// 获取应用运行时间 +export const getAppUptime = async () => { + return invoke("get_app_uptime"); +}; + // 安装/重装系统服务 export const installService = async () => { return invoke("install_service"); diff --git a/src/services/types.d.ts b/src/services/types.d.ts index 5c27dca0..e3c2bedc 100644 --- a/src/services/types.d.ts +++ b/src/services/types.d.ts @@ -787,6 +787,7 @@ interface IVergeConfig { webdav_url?: string; webdav_username?: string; webdav_password?: string; + home_cards?: Record; } interface IWebDavFile {