mirror of
https://github.com/clash-verge-rev/clash-verge-rev
synced 2025-05-04 21:47:32 +08:00
feat: home page
This commit is contained in:
parent
c25015ed54
commit
1f99cee78b
@ -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",
|
||||
|
325
pnpm-lock.yaml
generated
325
pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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<AtomicI64> = 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<String> {
|
||||
let sysinfo = PlatformSpecification::new();
|
||||
let info = format!("{:?}", sysinfo);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// 获取当前内核运行模式
|
||||
#[tauri::command]
|
||||
pub async fn get_running_mode() -> Result<String, String> {
|
||||
@ -34,3 +55,15 @@ pub async fn get_running_mode() -> Result<String, String> {
|
||||
pub async fn install_service() -> CmdResult {
|
||||
wrap_err!(service::reinstall_service().await)
|
||||
}
|
||||
|
||||
/// 获取应用的运行时间(毫秒)
|
||||
#[tauri::command]
|
||||
pub fn get_app_uptime() -> CmdResult<i64> {
|
||||
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)
|
||||
}
|
||||
|
@ -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<bool>,
|
||||
|
||||
/// 首页卡片设置
|
||||
/// 控制首页各个卡片的显示和隐藏
|
||||
pub home_cards: Option<serde_json::Value>,
|
||||
|
||||
/// 切换代理时自动关闭连接
|
||||
pub auto_close_connection: Option<bool>,
|
||||
|
||||
@ -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<bool>,
|
||||
pub enable_lite_mode: Option<bool>,
|
||||
pub enable_dns_settings: Option<bool>,
|
||||
pub home_cards: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl From<IVerge> for IVergeResponse {
|
||||
@ -528,6 +536,7 @@ impl From<IVerge> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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)]
|
||||
|
109
src/components/home/clash-info-card.tsx
Normal file
109
src/components/home/clash-info-card.tsx
Normal file
@ -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 (
|
||||
<EnhancedCard
|
||||
title={t("Clash Info")}
|
||||
icon={<DeveloperBoardOutlined />}
|
||||
iconColor="warning"
|
||||
action={null}
|
||||
>
|
||||
{clashInfo && (
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("Core Version")}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{clashVersion || "-"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("System Proxy Address")}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{clashInfo.server || "-"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("Mixed Port")}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{clashInfo.mixed_port || "-"}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("Uptime")}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{uptime}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("Rules Count")}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{rules.length}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</EnhancedCard>
|
||||
);
|
||||
};
|
250
src/components/home/clash-mode-card.tsx
Normal file
250
src/components/home/clash-mode-card.tsx
Normal file
@ -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<string>("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 <MultipleStopRounded fontSize="small" />;
|
||||
case "global":
|
||||
return <LanguageRounded fontSize="small" />;
|
||||
case "direct":
|
||||
return <DirectionsRounded fontSize="small" />;
|
||||
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 (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||
{/* 模式选择按钮组 */}
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
py: 1,
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{modeList.map((mode) => (
|
||||
<Paper
|
||||
key={mode}
|
||||
elevation={mode === localMode ? 2 : 0}
|
||||
onClick={() => 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)}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
textTransform: "capitalize",
|
||||
fontWeight: mode === localMode ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{t(mode)}
|
||||
</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* 说明文本区域 */}
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
my: 1,
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{localMode === "rule" && (
|
||||
<Fade in={true} timeout={200}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
sx={{
|
||||
width: "95%",
|
||||
textAlign: "center",
|
||||
color: "text.secondary",
|
||||
p: 0.8,
|
||||
borderRadius: 1,
|
||||
borderColor: "primary.main",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
backgroundColor: "background.paper",
|
||||
wordBreak: "break-word",
|
||||
hyphens: "auto",
|
||||
}}
|
||||
>
|
||||
{getModeDescription("rule")}
|
||||
</Typography>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{localMode === "global" && (
|
||||
<Fade in={true} timeout={200}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
sx={{
|
||||
width: "95%",
|
||||
textAlign: "center",
|
||||
color: "text.secondary",
|
||||
p: 0.8,
|
||||
borderRadius: 1,
|
||||
borderColor: "primary.main",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
backgroundColor: "background.paper",
|
||||
wordBreak: "break-word",
|
||||
hyphens: "auto",
|
||||
}}
|
||||
>
|
||||
{getModeDescription("global")}
|
||||
</Typography>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{localMode === "direct" && (
|
||||
<Fade in={true} timeout={200}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
sx={{
|
||||
width: "95%",
|
||||
textAlign: "center",
|
||||
color: "text.secondary",
|
||||
p: 0.8,
|
||||
borderRadius: 1,
|
||||
borderColor: "primary.main",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
backgroundColor: "background.paper",
|
||||
wordBreak: "break-word",
|
||||
hyphens: "auto",
|
||||
}}
|
||||
>
|
||||
{getModeDescription("direct")}
|
||||
</Typography>
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
653
src/components/home/current-proxy-card.tsx
Normal file
653
src/components/home/current-proxy-card.tsx
Normal file
@ -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: <SignalNone />,
|
||||
text: "未测试",
|
||||
color: "text.secondary",
|
||||
};
|
||||
if (delay >= 10000)
|
||||
return {
|
||||
icon: <SignalError />,
|
||||
text: "超时",
|
||||
color: "error.main",
|
||||
};
|
||||
if (delay >= 500)
|
||||
return {
|
||||
icon: <SignalWeak />,
|
||||
text: "延迟较高",
|
||||
color: "error.main",
|
||||
};
|
||||
if (delay >= 300)
|
||||
return {
|
||||
icon: <SignalMedium />,
|
||||
text: "延迟中等",
|
||||
color: "warning.main",
|
||||
};
|
||||
if (delay >= 200)
|
||||
return {
|
||||
icon: <SignalGood />,
|
||||
text: "延迟良好",
|
||||
color: "info.main",
|
||||
};
|
||||
return {
|
||||
icon: <SignalStrong />,
|
||||
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<string>(getSavedGroup());
|
||||
const [proxyOptions, setProxyOptions] = useState<ProxyOption[]>([]);
|
||||
const [selectedProxy, setSelectedProxy] = useState<string>("");
|
||||
const [displayProxy, setDisplayProxy] = useState<any>(null);
|
||||
const [records, setRecords] = useState<Record<string, any>>({});
|
||||
const [globalProxy, setGlobalProxy] = useState<string>(""); // 存储全局代理
|
||||
const [directProxy, setDirectProxy] = useState<any>(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 (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Typography noWrap>{selected}</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={delayManager.formatDelay(delayValue)}
|
||||
color={convertDelayColor(delayValue)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<EnhancedCard
|
||||
title={t("Current Node")}
|
||||
icon={
|
||||
<Tooltip
|
||||
title={
|
||||
proxyToDisplay
|
||||
? `${signalInfo.text}: ${delayManager.formatDelay(currentDelay)}`
|
||||
: "无代理节点"
|
||||
}
|
||||
>
|
||||
<Box sx={{ color: signalInfo.color }}>
|
||||
{proxyToDisplay ? signalInfo.icon : <SignalNone color="disabled" />}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
}
|
||||
iconColor={proxyToDisplay ? "primary" : undefined}
|
||||
action={
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={goToProxies}
|
||||
sx={{ borderRadius: 1.5 }}
|
||||
endIcon={<ChevronRight fontSize="small" />}
|
||||
>
|
||||
{t("Label-Proxies")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{proxyToDisplay ? (
|
||||
<Box>
|
||||
{/* 代理节点信息显示 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
p: 1,
|
||||
mb: 2,
|
||||
borderRadius: 1,
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.05),
|
||||
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="body1" fontWeight="medium">
|
||||
{proxyToDisplay.name}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{ display: "flex", alignItems: "center", flexWrap: "wrap" }}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{proxyToDisplay.type}
|
||||
</Typography>
|
||||
{isGlobalMode && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={t("Global Mode")}
|
||||
color="primary"
|
||||
sx={{ mr: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
{isDirectMode && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={t("Direct Mode")}
|
||||
color="success"
|
||||
sx={{ mr: 0.5 }}
|
||||
/>
|
||||
)}
|
||||
{/* 节点特性 */}
|
||||
{proxyToDisplay.udp && (
|
||||
<Chip size="small" label="UDP" variant="outlined" />
|
||||
)}
|
||||
{proxyToDisplay.tfo && (
|
||||
<Chip size="small" label="TFO" variant="outlined" />
|
||||
)}
|
||||
{proxyToDisplay.xudp && (
|
||||
<Chip size="small" label="XUDP" variant="outlined" />
|
||||
)}
|
||||
{proxyToDisplay.mptcp && (
|
||||
<Chip size="small" label="MPTCP" variant="outlined" />
|
||||
)}
|
||||
{proxyToDisplay.smux && (
|
||||
<Chip size="small" label="SMUX" variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 显示延迟 */}
|
||||
{proxyToDisplay && !isDirectMode && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={delayManager.formatDelay(
|
||||
delayManager.getDelayFix(proxyToDisplay, selectedGroup),
|
||||
)}
|
||||
color={convertDelayColor(
|
||||
delayManager.getDelayFix(proxyToDisplay, selectedGroup),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{/* 代理组选择器 */}
|
||||
<FormControl
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ mb: 1.5 }}
|
||||
>
|
||||
<InputLabel id="proxy-group-select-label">{t("Group")}</InputLabel>
|
||||
<Select
|
||||
labelId="proxy-group-select-label"
|
||||
value={selectedGroup}
|
||||
onChange={handleGroupChange}
|
||||
label={t("Group")}
|
||||
disabled={isGlobalMode || isDirectMode} // 特殊模式下禁用选择器
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<MenuItem key={group.name} value={group.name}>
|
||||
{group.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* 代理节点选择器 */}
|
||||
<FormControl fullWidth variant="outlined" size="small" sx={{ mb: 0 }}>
|
||||
<InputLabel id="proxy-select-label">{t("Proxy")}</InputLabel>
|
||||
<Select
|
||||
labelId="proxy-select-label"
|
||||
value={selectedProxy}
|
||||
onChange={handleProxyChange}
|
||||
label={t("Proxy")}
|
||||
disabled={isDirectMode} // 直连模式下禁用选择器
|
||||
renderValue={renderProxyValue}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
style: {
|
||||
maxHeight: 500,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{proxyOptions.map((proxy) => {
|
||||
const delayValue = delayManager.getDelayFix(
|
||||
records[proxy.name],
|
||||
selectedGroup,
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
key={proxy.name}
|
||||
value={proxy.name}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
pr: 1,
|
||||
}}
|
||||
>
|
||||
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
|
||||
{proxy.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={delayManager.formatDelay(delayValue)}
|
||||
color={convertDelayColor(delayValue)}
|
||||
sx={{
|
||||
minWidth: "60px",
|
||||
height: "22px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ textAlign: "center", py: 4 }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{t("No active proxy node")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</EnhancedCard>
|
||||
);
|
||||
};
|
94
src/components/home/enhanced-card.tsx
Normal file
94
src/components/home/enhanced-card.tsx
Normal file
@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: 2,
|
||||
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 1.5,
|
||||
width: 38,
|
||||
height: 38,
|
||||
mr: 1.5,
|
||||
backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
|
||||
color: theme.palette[iconColor].main,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
{typeof title === "string" ? (
|
||||
<Typography variant="h6" fontWeight="medium" fontSize={18}>
|
||||
{title}
|
||||
</Typography>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</Box>
|
||||
{action}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
p: noContentPadding ? 0 : 2,
|
||||
...(minHeight && { minHeight }),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
382
src/components/home/enhanced-traffic-graph.tsx
Normal file
382
src/components/home/enhanced-traffic-graph.tsx
Normal file
@ -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<EnhancedTrafficGraphRef>(
|
||||
(props, ref) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 时间范围状态(默认10分钟)
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(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<DataPoint[]>([]);
|
||||
|
||||
// 当前显示的数据点 - 根据选定的时间范围从缓冲区过滤
|
||||
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 = () => (
|
||||
<>
|
||||
{/* 上传标签 - 右上角 */}
|
||||
<text
|
||||
x="98%"
|
||||
y="7%"
|
||||
textAnchor="end"
|
||||
fill={colors.up}
|
||||
fontSize={12}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{t("Upload")}
|
||||
</text>
|
||||
|
||||
{/* 下载标签 - 右上角下方 */}
|
||||
<text
|
||||
x="98%"
|
||||
y="16%"
|
||||
textAnchor="end"
|
||||
fill={colors.down}
|
||||
fontSize={12}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{t("Download")}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
|
||||
// 共享图表配置
|
||||
const commonProps = {
|
||||
data: dataPoints,
|
||||
margin: { top: 10, right: 20, left: 0, bottom: 0 },
|
||||
};
|
||||
|
||||
// 曲线类型 - 使用平滑曲线
|
||||
const curveType = "basis";
|
||||
|
||||
// 共享图表子组件
|
||||
const commonChildren = (
|
||||
<>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke={colors.grid}
|
||||
opacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 10, fill: colors.text }}
|
||||
tickLine={{ stroke: colors.grid }}
|
||||
axisLine={{ stroke: colors.grid }}
|
||||
interval="preserveStart"
|
||||
tickFormatter={formatXLabel}
|
||||
minTickGap={timeRange === 1 ? 40 : 80}
|
||||
tickCount={Math.min(6, timeRange * 2)}
|
||||
domain={["dataMin", "dataMax"]}
|
||||
scale="auto"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatYAxis}
|
||||
tick={{ fontSize: 10, fill: colors.text }}
|
||||
tickLine={{ stroke: colors.grid }}
|
||||
axisLine={{ stroke: colors.grid }}
|
||||
width={40}
|
||||
domain={[0, "auto"]}
|
||||
padding={{ top: 10, bottom: 0 }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={formatTooltip}
|
||||
labelFormatter={(label) => `${t("Time")}: ${label}`}
|
||||
contentStyle={{
|
||||
backgroundColor: colors.tooltip,
|
||||
borderColor: colors.grid,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
itemStyle={{ color: colors.text }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
|
||||
{/* 可点击的时间范围标签 */}
|
||||
<g
|
||||
className="time-range-selector"
|
||||
onClick={handleTimeRangeClick}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<text
|
||||
x="1%"
|
||||
y="6%"
|
||||
textAnchor="start"
|
||||
fill={theme.palette.text.secondary}
|
||||
fontSize={11}
|
||||
fontWeight="bold"
|
||||
>
|
||||
{getTimeRangeText()}
|
||||
</text>
|
||||
</g>
|
||||
</>
|
||||
);
|
||||
|
||||
// 渲染图表 - 线图或面积图
|
||||
const renderChart = () => {
|
||||
// 共享的线条/区域配置
|
||||
const commonLineProps = {
|
||||
dot: false,
|
||||
strokeWidth: 2,
|
||||
connectNulls: false,
|
||||
activeDot: { r: 4, strokeWidth: 1 },
|
||||
};
|
||||
|
||||
return chartStyle === "line" ? (
|
||||
<LineChart {...commonProps}>
|
||||
{commonChildren}
|
||||
<Line
|
||||
type="basis"
|
||||
{...commonLineProps}
|
||||
dataKey="up"
|
||||
name={t("Upload")}
|
||||
stroke={colors.up}
|
||||
/>
|
||||
<Line
|
||||
type="basis"
|
||||
{...commonLineProps}
|
||||
dataKey="down"
|
||||
name={t("Download")}
|
||||
stroke={colors.down}
|
||||
/>
|
||||
{renderInnerLabels()}
|
||||
</LineChart>
|
||||
) : (
|
||||
<AreaChart {...commonProps}>
|
||||
{commonChildren}
|
||||
<Area
|
||||
type="basis"
|
||||
{...commonLineProps}
|
||||
dataKey="up"
|
||||
name={t("Upload")}
|
||||
stroke={colors.up}
|
||||
fill={colors.up}
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
<Area
|
||||
type="basis"
|
||||
{...commonLineProps}
|
||||
dataKey="down"
|
||||
name={t("Download")}
|
||||
stroke={colors.down}
|
||||
fill={colors.down}
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
{renderInnerLabels()}
|
||||
</AreaChart>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 1,
|
||||
padding: 1,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={toggleStyle}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{renderChart()}
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
394
src/components/home/enhanced-traffic-stats.tsx
Normal file
394
src/components/home/enhanced-traffic-stats.tsx
Normal file
@ -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<EnhancedTrafficGraphRef>(null);
|
||||
const pageVisible = useVisibility();
|
||||
const [isDebug, setIsDebug] = useState(false);
|
||||
const [trafficStats, setTrafficStats] = useState<TrafficStatData>({
|
||||
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<ITrafficItem>({
|
||||
up: 0,
|
||||
down: 0,
|
||||
});
|
||||
const [memoryData, setMemoryData] = useState<MemoryUsage>({ 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) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha(getColorFromPalette(color), 0.05),
|
||||
border: `1px solid ${alpha(getColorFromPalette(color), 0.15)}`,
|
||||
//height: "80px",
|
||||
padding: "8px",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
cursor: "pointer",
|
||||
"&:hover": {
|
||||
bgcolor: alpha(getColorFromPalette(color), 0.1),
|
||||
border: `1px solid ${alpha(getColorFromPalette(color), 0.3)}`,
|
||||
boxShadow: `0 4px 8px rgba(0,0,0,0.05)`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 图标容器 */}
|
||||
<Grid
|
||||
component="div"
|
||||
sx={{
|
||||
mr: 1,
|
||||
ml: "2px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
bgcolor: alpha(getColorFromPalette(color), 0.1),
|
||||
color: getColorFromPalette(color),
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Grid>
|
||||
|
||||
{/* 文本内容 */}
|
||||
<Grid component="div" sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{title}
|
||||
</Typography>
|
||||
<Grid component="div" sx={{ display: "flex", alignItems: "baseline" }}>
|
||||
<Typography variant="body1" fontWeight="bold" noWrap sx={{ mr: 0.5 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{unit}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
// 渲染流量图表
|
||||
const renderTrafficGraph = () => {
|
||||
if (!trafficGraph || !pageVisible) return null;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
height: 130,
|
||||
cursor: "pointer",
|
||||
border: `1px solid ${alpha(theme.palette.divider, 0.2)}`,
|
||||
borderRadius: 2,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
onClick={() => trafficRef.current?.toggleStyle()}
|
||||
>
|
||||
<div style={{ height: "100%", position: "relative" }}>
|
||||
<EnhancedTrafficGraph ref={trafficRef} />
|
||||
{isDebug && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
left: "2px",
|
||||
zIndex: 10,
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
color: "white",
|
||||
fontSize: "8px",
|
||||
padding: "2px 4px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
DEBUG: {!!trafficRef.current ? "图表已初始化" : "图表未初始化"}
|
||||
<br />
|
||||
{new Date().toISOString().slice(11, 19)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
// 统计卡片配置
|
||||
const statCards = [
|
||||
{
|
||||
icon: <ArrowUpwardRounded fontSize="small" />,
|
||||
title: t("Upload Speed"),
|
||||
value: up,
|
||||
unit: `${upUnit}/s`,
|
||||
color: "secondary" as const,
|
||||
},
|
||||
{
|
||||
icon: <ArrowDownwardRounded fontSize="small" />,
|
||||
title: t("Download Speed"),
|
||||
value: down,
|
||||
unit: `${downUnit}/s`,
|
||||
color: "primary" as const,
|
||||
},
|
||||
{
|
||||
icon: <LinkRounded fontSize="small" />,
|
||||
title: t("Active Connections"),
|
||||
value: trafficStats.activeConnections,
|
||||
unit: "",
|
||||
color: "success" as const,
|
||||
},
|
||||
{
|
||||
icon: <CloudUploadRounded fontSize="small" />,
|
||||
title: t("Uploaded"),
|
||||
value: uploadTotal,
|
||||
unit: uploadTotalUnit,
|
||||
color: "secondary" as const,
|
||||
},
|
||||
{
|
||||
icon: <CloudDownloadRounded fontSize="small" />,
|
||||
title: t("Downloaded"),
|
||||
value: downloadTotal,
|
||||
unit: downloadTotalUnit,
|
||||
color: "primary" as const,
|
||||
},
|
||||
{
|
||||
icon: <MemoryRounded fontSize="small" />,
|
||||
title: t("Memory Usage"),
|
||||
value: inuse,
|
||||
unit: inuseUnit,
|
||||
color: "error" as const,
|
||||
onClick: isDebug ? async () => await gc() : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
|
||||
<Grid size={12}>
|
||||
{/* 流量图表区域 */}
|
||||
{renderTrafficGraph()}
|
||||
</Grid>
|
||||
{/* 统计卡片区域 */}
|
||||
{statCards.map((card, index) => (
|
||||
<Grid size={4}>
|
||||
<CompactStatCard {...card} />
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
297
src/components/home/home-profile-card.tsx
Normal file
297
src/components/home/home-profile-card.tsx
Normal file
@ -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 (
|
||||
<EnhancedCard
|
||||
title={
|
||||
current ? (
|
||||
current.home ? (
|
||||
<Link
|
||||
component="button"
|
||||
variant="h6"
|
||||
fontWeight="medium"
|
||||
fontSize={18}
|
||||
onClick={() => current.home && openWebUrl(current.home)}
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
{current.name}
|
||||
<LaunchOutlined
|
||||
fontSize="inherit"
|
||||
sx={{ ml: 0.5, fontSize: "0.8rem", opacity: 0.7 }}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
current.name
|
||||
)
|
||||
) : (
|
||||
t("Profiles")
|
||||
)
|
||||
}
|
||||
icon={<CloudUploadOutlined />}
|
||||
iconColor="info"
|
||||
action={
|
||||
current && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={goToProfiles}
|
||||
endIcon={<StorageOutlined fontSize="small" />}
|
||||
sx={{ borderRadius: 1.5 }}
|
||||
>
|
||||
{t("Label-Profiles")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{current ? (
|
||||
// 已导入订阅,显示详情
|
||||
<Box>
|
||||
<Stack spacing={2}>
|
||||
{current.url && (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<DnsOutlined fontSize="small" color="action" />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("From")}:{" "}
|
||||
{current.home ? (
|
||||
<Link
|
||||
component="button"
|
||||
fontWeight="medium"
|
||||
onClick={() => current.home && openWebUrl(current.home)}
|
||||
sx={{ display: "inline-flex", alignItems: "center" }}
|
||||
>
|
||||
{parseUrl(current.url)}
|
||||
<LaunchOutlined
|
||||
fontSize="inherit"
|
||||
sx={{ ml: 0.5, fontSize: "0.8rem", opacity: 0.7 }}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<Box component="span" fontWeight="medium">
|
||||
{parseUrl(current.url)}
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{current.updated && (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<UpdateOutlined
|
||||
fontSize="small"
|
||||
color="action"
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
animation: updating
|
||||
? `${round} 1.5s linear infinite`
|
||||
: "none",
|
||||
}}
|
||||
onClick={onUpdateProfile}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ cursor: "pointer" }}
|
||||
onClick={onUpdateProfile}
|
||||
>
|
||||
{t("Update Time")}:{" "}
|
||||
<Box component="span" fontWeight="medium">
|
||||
{dayjs(current.updated * 1000).format("YYYY-MM-DD HH:mm")}
|
||||
</Box>
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{current.extra && (
|
||||
<>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<SpeedOutlined fontSize="small" color="action" />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("Used / Total")}:{" "}
|
||||
<Box component="span" fontWeight="medium">
|
||||
{parseTraffic(
|
||||
current.extra.upload + current.extra.download,
|
||||
)}{" "}
|
||||
/ {parseTraffic(current.extra.total)}
|
||||
</Box>
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{current.extra.expire > 0 && (
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<EventOutlined fontSize="small" color="action" />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("Expire Time")}:{" "}
|
||||
<Box component="span" fontWeight="medium">
|
||||
{parseExpire(current.extra.expire)}
|
||||
</Box>
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 0.5, display: "block" }}
|
||||
>
|
||||
{Math.min(
|
||||
Math.round(
|
||||
((current.extra.download + current.extra.upload) *
|
||||
100) /
|
||||
(current.extra.total + 0.01),
|
||||
) + 1,
|
||||
100,
|
||||
)}
|
||||
%
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(
|
||||
Math.round(
|
||||
((current.extra.download + current.extra.upload) *
|
||||
100) /
|
||||
(current.extra.total + 0.01),
|
||||
) + 1,
|
||||
100,
|
||||
)}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.12),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : (
|
||||
// 未导入订阅,显示导入按钮
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
py: 2.4,
|
||||
cursor: "pointer",
|
||||
"&:hover": { bgcolor: "action.hover" },
|
||||
borderRadius: 2,
|
||||
}}
|
||||
onClick={goToProfiles}
|
||||
>
|
||||
<CloudUploadOutlined
|
||||
sx={{ fontSize: 60, color: "primary.main", mb: 2 }}
|
||||
/>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("Import")} {t("Profiles")}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("Click to import subscription")}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</EnhancedCard>
|
||||
);
|
||||
};
|
298
src/components/home/ip-info-card.tsx
Normal file
298
src/components/home/ip-info-card.tsx
Normal file
@ -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<any>(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 }) => (
|
||||
<Box sx={{ mb: 0.7, display: "flex", alignItems: "flex-start" }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
minwidth: 60,
|
||||
mr: 0.5,
|
||||
flexShrink: 0,
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{label}:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
ml: 0.5,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "normal",
|
||||
flexGrow: 1, // 让内容占用剩余空间
|
||||
}}
|
||||
>
|
||||
{value || t("Unknown")}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<EnhancedCard
|
||||
title={t("IP Information")}
|
||||
icon={<LocationOnOutlined />}
|
||||
iconColor="info"
|
||||
action={
|
||||
<IconButton size="small" onClick={handleRefresh} disabled={loading}>
|
||||
<RefreshOutlined />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{loading ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Skeleton variant="text" width="60%" height={34} />
|
||||
<Skeleton variant="text" width="80%" height={24} />
|
||||
<Skeleton variant="text" width="70%" height={24} />
|
||||
<Skeleton variant="text" width="50%" height={24} />
|
||||
</Box>
|
||||
) : error ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: "error.main",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="error">
|
||||
{error}
|
||||
</Typography>
|
||||
<Button onClick={handleRefresh} sx={{ mt: 2 }}>
|
||||
{t("Retry")}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* 左侧:国家和IP地址 */}
|
||||
<Box sx={{ width: "40%", overflow: "hidden" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
mb: 1,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: "1.5rem",
|
||||
mr: 1,
|
||||
display: "inline-block",
|
||||
width: 28,
|
||||
textAlign: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{getCountryFlag(ipInfo?.country_code)}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: "medium",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{ipInfo?.country || t("Unknown")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
{t("IP")}:
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
ml: 1,
|
||||
overflow: "hidden",
|
||||
maxWidth: "calc(100% - 30px)",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{showIp ? ipInfo?.ip : "••••••••••"}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={toggleShowIp}>
|
||||
{showIp ? (
|
||||
<VisibilityOffOutlined fontSize="small" />
|
||||
) : (
|
||||
<VisibilityOutlined fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<InfoItem
|
||||
label={t("ASN")}
|
||||
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:组织、ISP和位置信息 */}
|
||||
<Box sx={{ width: "60%", overflow: "auto" }}>
|
||||
<InfoItem label={t("ISP")} value={ipInfo?.isp} />
|
||||
|
||||
<InfoItem label={t("ORG")} value={ipInfo?.asn_organization} />
|
||||
|
||||
<InfoItem
|
||||
label={t("Location")}
|
||||
value={[ipInfo?.city, ipInfo?.region]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
/>
|
||||
|
||||
<InfoItem label={t("Timezone")} value={ipInfo?.timezone} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: "auto",
|
||||
pt: 0.5,
|
||||
borderTop: 1,
|
||||
borderColor: "divider",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
opacity: 0.7,
|
||||
fontSize: "0.7rem",
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">
|
||||
{t("Auto refresh")}: {countdown}s
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{ipInfo?.country_code}, {ipInfo?.longitude?.toFixed(2)},{" "}
|
||||
{ipInfo?.latitude?.toFixed(2)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</EnhancedCard>
|
||||
);
|
||||
};
|
280
src/components/home/proxy-tun-card.tsx
Normal file
280
src/components/home/proxy-tun-card.tsx
Normal file
@ -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<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<string>("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 (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||
{/* 选项卡 */}
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={activeTab === "system" ? 2 : 0}
|
||||
onClick={() => 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%)",
|
||||
}
|
||||
: {},
|
||||
}}
|
||||
>
|
||||
<ComputerRounded fontSize="small" />
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: activeTab === "system" ? 600 : 400 }}
|
||||
>
|
||||
{t("System Proxy")}
|
||||
</Typography>
|
||||
{sysproxy?.enable && (
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
bgcolor: activeTab === "system" ? "#fff" : "success.main",
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
<Paper
|
||||
elevation={activeTab === "tun" ? 2 : 0}
|
||||
onClick={() => 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%)",
|
||||
}
|
||||
: {},
|
||||
}}
|
||||
>
|
||||
<TroubleshootRounded fontSize="small" />
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: activeTab === "tun" ? 600 : 400 }}
|
||||
>
|
||||
{t("Tun Mode")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
{/* 说明文本区域 */}
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
my: 1,
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{activeTab === "system" && (
|
||||
<Fade in={true} timeout={200}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
sx={{
|
||||
width: "95%",
|
||||
textAlign: "center",
|
||||
color: "text.secondary",
|
||||
p: 0.8,
|
||||
borderRadius: 1,
|
||||
borderColor: "primary.main",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
backgroundColor: "background.paper",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 0.5,
|
||||
wordBreak: "break-word",
|
||||
hyphens: "auto",
|
||||
}}
|
||||
>
|
||||
{getTabDescription("system")}
|
||||
<Tooltip title={t("System Proxy Info")}>
|
||||
<HelpOutlineRounded
|
||||
sx={{ fontSize: 14, opacity: 0.7, flexShrink: 0 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Typography>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
{activeTab === "tun" && (
|
||||
<Fade in={true} timeout={200}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="div"
|
||||
sx={{
|
||||
width: "95%",
|
||||
textAlign: "center",
|
||||
color: "text.secondary",
|
||||
p: 0.8,
|
||||
borderRadius: 1,
|
||||
borderColor: "primary.main",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
backgroundColor: "background.paper",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 0.5,
|
||||
wordBreak: "break-word",
|
||||
hyphens: "auto",
|
||||
}}
|
||||
>
|
||||
{getTabDescription("tun")}
|
||||
<Tooltip title={t("Tun Mode Info")}>
|
||||
<HelpOutlineRounded
|
||||
sx={{ fontSize: 14, opacity: 0.7, flexShrink: 0 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Typography>
|
||||
</Fade>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 控制开关部分 */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0,
|
||||
p: 1,
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.04),
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<ProxyControlSwitches
|
||||
onError={handleError}
|
||||
label={activeTab === "system" ? t("System Proxy") : t("Tun Mode")}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
240
src/components/home/system-info-card.tsx
Normal file
240
src/components/home/system-info-card.tsx
Normal file
@ -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<string>("");
|
||||
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<string>("-");
|
||||
|
||||
// 在组件挂载时检查本地存储中的最后更新时间
|
||||
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 (
|
||||
<EnhancedCard
|
||||
title={t("System Info")}
|
||||
icon={<InfoOutlined />}
|
||||
iconColor="error"
|
||||
action={
|
||||
<IconButton size="small" onClick={goToSettings} title={t("Settings")}>
|
||||
<SettingsOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
{verge && (
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("OS Info")}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{osInfo}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("Auto Launch")}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={verge.enable_auto_launch ? t("Enabled") : t("Disabled")}
|
||||
color={verge.enable_auto_launch ? "success" : "default"}
|
||||
variant={verge.enable_auto_launch ? "filled" : "outlined"}
|
||||
onClick={toggleAutoLaunch}
|
||||
sx={{ cursor: "pointer" }}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("Running Mode")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="medium"
|
||||
onClick={handleRunningModeClick}
|
||||
sx={{
|
||||
cursor: runningMode === "sidecar" ? "pointer" : "default",
|
||||
textDecoration:
|
||||
runningMode === "sidecar" ? "underline" : "none",
|
||||
"&:hover": {
|
||||
opacity: runningMode === "sidecar" ? 0.7 : 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{runningMode === "service"
|
||||
? t("Service Mode")
|
||||
: t("Sidecar Mode")}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("Last Check Update")}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="medium"
|
||||
onClick={onCheckUpdate}
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
"&:hover": {
|
||||
opacity: 0.7,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{lastCheckUpdate}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Stack direction="row" justifyContent="space-between">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("Verge Version")}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
v{appVersion}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</EnhancedCard>
|
||||
);
|
||||
};
|
172
src/components/home/test-card.tsx
Normal file
172
src/components/home/test-card.tsx
Normal file
@ -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<IVergeTestItem>,
|
||||
) => {
|
||||
if (patch) {
|
||||
const newList = testList.map((x) => {
|
||||
if (x.uid === uid) {
|
||||
return { ...x, ...patch };
|
||||
}
|
||||
return x;
|
||||
});
|
||||
mutateVerge({ ...verge, test_list: newList }, false);
|
||||
} else {
|
||||
mutateVerge();
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteTestListItem = (uid: string) => {
|
||||
const newList = testList.filter((x) => x.uid !== uid);
|
||||
patchVerge({ test_list: newList });
|
||||
mutateVerge({ ...verge, test_list: newList }, false);
|
||||
};
|
||||
|
||||
const 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<TestViewerRef>(null);
|
||||
|
||||
return (
|
||||
<EnhancedCard
|
||||
title={t("Website Tests")}
|
||||
icon={<NetworkCheck />}
|
||||
action={
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Tooltip title={t("Test All")} arrow>
|
||||
<IconButton size="small" onClick={() => emit("verge://test-all")}>
|
||||
<NetworkCheck fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("Create Test")} arrow>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => viewerRef.current?.create()}
|
||||
>
|
||||
<Add fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ScrollBox>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<Grid container spacing={1} columns={12}>
|
||||
<SortableContext items={testList.map((x) => x.uid)}>
|
||||
{testList.map((item) => (
|
||||
<Grid key={item.uid} size={3}>
|
||||
<TestItem
|
||||
id={item.uid}
|
||||
itemData={item}
|
||||
onEdit={() => viewerRef.current?.edit(item)}
|
||||
onDelete={onDeleteTestListItem}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</SortableContext>
|
||||
</Grid>
|
||||
</DndContext>
|
||||
</ScrollBox>
|
||||
|
||||
<TestViewer ref={viewerRef} onChange={onTestListItemChange} />
|
||||
</EnhancedCard>
|
||||
);
|
||||
};
|
@ -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 }));
|
||||
|
@ -57,7 +57,7 @@ export const ProxyHead = (props: Props) => {
|
||||
<IconButton
|
||||
size="small"
|
||||
color="inherit"
|
||||
title={t("Location")}
|
||||
title={t("locate")}
|
||||
onClick={props.onLocation}
|
||||
>
|
||||
<MyLocationRounded />
|
||||
|
296
src/components/shared/ProxyControlSwitches.tsx
Normal file
296
src/components/shared/ProxyControlSwitches.tsx
Normal file
@ -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<DialogRef>(null);
|
||||
const tunRef = useRef<DialogRef>(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<IVergeConfig>) => {
|
||||
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 (
|
||||
<Box>
|
||||
{label && (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: "15px",
|
||||
fontWeight: "500",
|
||||
mb: 0.5,
|
||||
display: "none", // 隐藏标签,因为在父组件中已经有标签了
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 仅显示当前选中的开关 */}
|
||||
{isSystemProxyMode && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
p: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: enable_system_proxy
|
||||
? alpha(theme.palette.success.main, 0.07)
|
||||
: "transparent",
|
||||
transition: "background-color 0.3s",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
{proxy_auto_config ? (
|
||||
autoproxy?.enable ? (
|
||||
<PlayCircleOutlineRounded
|
||||
sx={{ color: "success.main", mr: 1.5, fontSize: 28 }}
|
||||
/>
|
||||
) : (
|
||||
<PauseCircleOutlineRounded
|
||||
sx={{ color: "text.disabled", mr: 1.5, fontSize: 28 }}
|
||||
/>
|
||||
)
|
||||
) : sysproxy?.enable ? (
|
||||
<PlayCircleOutlineRounded
|
||||
sx={{ color: "success.main", mr: 1.5, fontSize: 28 }}
|
||||
/>
|
||||
) : (
|
||||
<PauseCircleOutlineRounded
|
||||
sx={{ color: "text.disabled", mr: 1.5, fontSize: 28 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ fontWeight: 500, fontSize: "15px" }}
|
||||
>
|
||||
{t("System Proxy")}
|
||||
</Typography>
|
||||
{/* <Typography variant="caption" color="text.secondary">
|
||||
{sysproxy?.enable
|
||||
? t("Proxy is active")
|
||||
: t("Enable this for most users")
|
||||
}
|
||||
</Typography> */}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Tooltip title={t("System Proxy Info")} arrow>
|
||||
<Box
|
||||
sx={{
|
||||
mr: 1,
|
||||
color: "text.secondary",
|
||||
"&:hover": { color: "primary.main" },
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => sysproxyRef.current?.open()}
|
||||
>
|
||||
<SettingsRounded fontSize="small" />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<GuardState
|
||||
value={enable_system_proxy ?? false}
|
||||
valueProps="checked"
|
||||
onCatch={onError}
|
||||
onFormat={onSwitchFormat}
|
||||
onChange={(e) => onChangeData({ enable_system_proxy: e })}
|
||||
onGuard={async (e) => {
|
||||
await patchVerge({ enable_system_proxy: e });
|
||||
await updateProxyStatus();
|
||||
}}
|
||||
>
|
||||
<Switch edge="end" />
|
||||
</GuardState>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isTunMode && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
p: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: enable_tun_mode
|
||||
? alpha(theme.palette.success.main, 0.07)
|
||||
: "transparent",
|
||||
opacity: isSidecarMode ? 0.6 : 1,
|
||||
transition: "background-color 0.3s",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
{enable_tun_mode ? (
|
||||
<PlayCircleOutlineRounded
|
||||
sx={{ color: "success.main", mr: 1.5, fontSize: 28 }}
|
||||
/>
|
||||
) : (
|
||||
<PauseCircleOutlineRounded
|
||||
sx={{ color: "text.disabled", mr: 1.5, fontSize: 28 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ fontWeight: 500, fontSize: "15px" }}
|
||||
>
|
||||
{t("Tun Mode")}
|
||||
</Typography>
|
||||
{/* <Typography variant="caption" color="text.secondary">
|
||||
{isSidecarMode
|
||||
? t("TUN requires Service Mode")
|
||||
: t("For special applications")
|
||||
}
|
||||
</Typography> */}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
{isSidecarMode && (
|
||||
<Tooltip title={t("Install Service")} arrow>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={onInstallService}
|
||||
sx={{ mr: 1, minWidth: "32px", p: "4px" }}
|
||||
>
|
||||
<BuildRounded fontSize="small" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title={t("Tun Mode Info")} arrow>
|
||||
<Box
|
||||
sx={{
|
||||
mr: 1,
|
||||
color: "text.secondary",
|
||||
"&:hover": { color: "primary.main" },
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => tunRef.current?.open()}
|
||||
>
|
||||
<SettingsRounded fontSize="small" />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<GuardState
|
||||
value={enable_tun_mode ?? false}
|
||||
valueProps="checked"
|
||||
onCatch={onError}
|
||||
onFormat={onSwitchFormat}
|
||||
onChange={(e) => {
|
||||
// 当在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 });
|
||||
}}
|
||||
>
|
||||
<Switch edge="end" disabled={isSidecarMode} />
|
||||
</GuardState>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 引用对话框组件 */}
|
||||
<SysproxyViewer ref={sysproxyRef} />
|
||||
<TunViewer ref={tunRef} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxyControlSwitches;
|
@ -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],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
@ -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) => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "center" }}>
|
||||
<Typography variant="h6" component="h2" noWrap title={name}>
|
||||
{name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "center" }}>{name}</Box>
|
||||
</Box>
|
||||
<Divider sx={{ marginTop: "8px" }} />
|
||||
<Box
|
||||
|
82
src/hooks/use-current-proxy.ts
Normal file
82
src/hooks/use-current-proxy.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import useSWR from "swr";
|
||||
import { useMemo } from "react";
|
||||
import { getProxies } from "@/services/api";
|
||||
import { getClashConfig } from "@/services/api";
|
||||
|
||||
// 获取当前代理节点信息的自定义Hook
|
||||
export const useCurrentProxy = () => {
|
||||
// 获取代理信息
|
||||
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,
|
||||
};
|
||||
};
|
@ -32,7 +32,7 @@
|
||||
"global": "عالمي",
|
||||
"direct": "مباشر",
|
||||
"script": "سكريبت",
|
||||
"Location": "الموقع",
|
||||
"locate": "الموقع",
|
||||
"Delay check": "فحص التأخير",
|
||||
"Sort by default": "الترتيب الافتراضي",
|
||||
"Sort by delay": "الترتيب حسب التأخير",
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -32,7 +32,7 @@
|
||||
"global": "جهانی",
|
||||
"direct": "مستقیم",
|
||||
"script": "اسکریپت",
|
||||
"Location": "موقعیت",
|
||||
"locate": "موقعیت",
|
||||
"Delay check": "بررسی تأخیر",
|
||||
"Sort by default": "مرتبسازی بر اساس پیشفرض",
|
||||
"Sort by delay": "مرتبسازی بر اساس تأخیر",
|
||||
|
@ -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",
|
||||
|
@ -32,7 +32,7 @@
|
||||
"global": "глобальный",
|
||||
"direct": "прямой",
|
||||
"script": "скриптовый",
|
||||
"Location": "Местоположение",
|
||||
"locate": "Местоположение",
|
||||
"Delay check": "Проверка задержки",
|
||||
"Sort by default": "Сортировать по умолчанию",
|
||||
"Sort by delay": "Сортировать по задержке",
|
||||
|
@ -32,7 +32,7 @@
|
||||
"global": "глобаль",
|
||||
"direct": "туры",
|
||||
"script": "скриптлы",
|
||||
"Location": "Урын",
|
||||
"locate": "Урын",
|
||||
"Delay check": "Задержканы тикшерү",
|
||||
"Sort by default": "Башлангыч итеп сортлау",
|
||||
"Sort by delay": "Задержка буенча сортлау",
|
||||
|
@ -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": "自动刷新"
|
||||
}
|
||||
|
@ -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: [<HomeRoundedIcon />],
|
||||
element: <HomePage />,
|
||||
},
|
||||
{
|
||||
label: "Label-Proxies",
|
||||
path: "/",
|
||||
@ -54,12 +60,6 @@ export const routers = [
|
||||
icon: [<SubjectRoundedIcon />, <LogsSvg />],
|
||||
element: <LogsPage />,
|
||||
},
|
||||
{
|
||||
label: "Label-Test",
|
||||
path: "/test",
|
||||
icon: [<WifiTetheringRoundedIcon />, <TestSvg />],
|
||||
element: <TestPage />,
|
||||
},
|
||||
{
|
||||
label: "Label-Settings",
|
||||
path: "/settings",
|
||||
|
381
src/pages/home.tsx
Normal file
381
src/pages/home.tsx
Normal file
@ -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<HomeCardsSettings>(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 (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t("Home Settings")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.profile || false}
|
||||
onChange={() => handleToggle("profile")}
|
||||
/>
|
||||
}
|
||||
label={t("Profile Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.proxy || false}
|
||||
onChange={() => handleToggle("proxy")}
|
||||
/>
|
||||
}
|
||||
label={t("Current Proxy Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.network || false}
|
||||
onChange={() => handleToggle("network")}
|
||||
/>
|
||||
}
|
||||
label={t("Network Settings Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.mode || false}
|
||||
onChange={() => handleToggle("mode")}
|
||||
/>
|
||||
}
|
||||
label={t("Proxy Mode Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.traffic || false}
|
||||
onChange={() => handleToggle("traffic")}
|
||||
/>
|
||||
}
|
||||
label={t("Traffic Stats Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.test || false}
|
||||
onChange={() => handleToggle("test")}
|
||||
/>
|
||||
}
|
||||
label={t("Website Tests Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.ip || false}
|
||||
onChange={() => handleToggle("ip")}
|
||||
/>
|
||||
}
|
||||
label={t("IP Information Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.clashinfo || false}
|
||||
onChange={() => handleToggle("clashinfo")}
|
||||
/>
|
||||
}
|
||||
label={t("Clash Info Cards")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.systeminfo || false}
|
||||
onChange={() => handleToggle("systeminfo")}
|
||||
/>
|
||||
}
|
||||
label={t("System Info Cards")}
|
||||
/>
|
||||
</FormGroup>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t("Cancel")}</Button>
|
||||
<Button onClick={handleSave} color="primary">
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
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<HomeCardsSettings>(
|
||||
(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 (
|
||||
<BasePage
|
||||
title={t("Label-Home")}
|
||||
contentStyle={{ padding: 2 }}
|
||||
header={
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Tooltip title={t("Manual")} arrow>
|
||||
<IconButton onClick={toGithubDoc} size="small" color="inherit">
|
||||
<HelpOutlineRounded />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("Home Settings")} arrow>
|
||||
<IconButton onClick={openSettings} size="small" color="inherit">
|
||||
<SettingsOutlined />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Grid container spacing={1.5} columns={{ xs: 6, sm: 6, md: 12 }}>
|
||||
{/* 订阅和当前节点部分 */}
|
||||
{homeCards.profile && (
|
||||
<Grid size={6}>
|
||||
<HomeProfileCard current={current} />
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{homeCards.proxy && (
|
||||
<Grid size={6}>
|
||||
<CurrentProxyCard />
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 代理和网络设置区域 */}
|
||||
{homeCards.network && (
|
||||
<Grid size={6}>
|
||||
<NetworkSettingsCard />
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{homeCards.mode && (
|
||||
<Grid size={6}>
|
||||
<ClashModeEnhancedCard />
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 增强的流量统计区域 */}
|
||||
{homeCards.traffic && (
|
||||
<Grid size={12}>
|
||||
<EnhancedCard
|
||||
title={t("Traffic Stats")}
|
||||
icon={<SpeedOutlined />}
|
||||
iconColor="secondary"
|
||||
minHeight={280}
|
||||
>
|
||||
<EnhancedTrafficStats />
|
||||
</EnhancedCard>
|
||||
</Grid>
|
||||
)}
|
||||
{/* 测试网站部分 */}
|
||||
{homeCards.test && (
|
||||
<Grid size={6}>
|
||||
<TestCard />
|
||||
</Grid>
|
||||
)}
|
||||
{/* IP信息卡片 */}
|
||||
{homeCards.ip && (
|
||||
<Grid size={6}>
|
||||
<IpInfoCard />
|
||||
</Grid>
|
||||
)}
|
||||
{/* Clash信息 */}
|
||||
{homeCards.clashinfo && (
|
||||
<Grid size={6}>
|
||||
<ClashInfoCard />
|
||||
</Grid>
|
||||
)}
|
||||
{/* 系统信息 */}
|
||||
{homeCards.systeminfo && (
|
||||
<Grid size={6}>
|
||||
<SystemInfoCard />
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
{/* 首页设置弹窗 */}
|
||||
<HomeSettingsDialog
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
homeCards={homeCards}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</BasePage>
|
||||
);
|
||||
};
|
||||
|
||||
// 增强版网络设置卡片组件
|
||||
const NetworkSettingsCard = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<EnhancedCard
|
||||
title={t("Network Settings")}
|
||||
icon={<DnsOutlined />}
|
||||
iconColor="primary"
|
||||
action={null}
|
||||
>
|
||||
<ProxyTunCard />
|
||||
</EnhancedCard>
|
||||
);
|
||||
};
|
||||
|
||||
// 增强版 Clash 模式卡片组件
|
||||
const ClashModeEnhancedCard = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<EnhancedCard
|
||||
title={t("Proxy Mode")}
|
||||
icon={<RouterOutlined />}
|
||||
iconColor="info"
|
||||
action={null}
|
||||
>
|
||||
<ClashModeCard />
|
||||
</EnhancedCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
@ -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 = () => {
|
||||
</ButtonGroup>
|
||||
}
|
||||
>
|
||||
<Grid container spacing={{ xs: 1.5, lg: 1.5 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Grid container spacing={1.5} columns={{ xs: 6, sm: 6, md: 12 }}>
|
||||
<Grid size={6}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
@ -92,7 +86,7 @@ const SettingPage = () => {
|
||||
<SettingClash onError={onError} />
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Grid size={6}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
|
@ -253,7 +253,7 @@ export const deleteConnection = async (id: string) => {
|
||||
// Close all connections
|
||||
export const closeAllConnections = async () => {
|
||||
const instance = await getAxios();
|
||||
await instance.delete<any, any>(`/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;
|
||||
};
|
||||
};
|
||||
|
@ -225,6 +225,10 @@ export async function exportDiagnosticInfo() {
|
||||
return invoke("export_diagnostic_info");
|
||||
}
|
||||
|
||||
export async function getSystemInfo() {
|
||||
return invoke<string>("get_system_info");
|
||||
}
|
||||
|
||||
export async function copyIconFile(
|
||||
path: string,
|
||||
name: "common" | "sysproxy" | "tun",
|
||||
@ -301,6 +305,11 @@ export const getRunningMode = async () => {
|
||||
return invoke<string>("get_running_mode");
|
||||
};
|
||||
|
||||
// 获取应用运行时间
|
||||
export const getAppUptime = async () => {
|
||||
return invoke<number>("get_app_uptime");
|
||||
};
|
||||
|
||||
// 安装/重装系统服务
|
||||
export const installService = async () => {
|
||||
return invoke<void>("install_service");
|
||||
|
1
src/services/types.d.ts
vendored
1
src/services/types.d.ts
vendored
@ -787,6 +787,7 @@ interface IVergeConfig {
|
||||
webdav_url?: string;
|
||||
webdav_username?: string;
|
||||
webdav_password?: string;
|
||||
home_cards?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
interface IWebDavFile {
|
||||
|
Loading…
x
Reference in New Issue
Block a user