Merge Official Source
2260
CHANGELOG.md
Normal file
7
Caddyfile
Normal file
@@ -0,0 +1,7 @@
|
||||
:80 {
|
||||
file_server
|
||||
|
||||
root * .
|
||||
|
||||
try_files {path} /index.html
|
||||
}
|
||||
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM --platform=linux/amd64 docker.io/guergeiro/pnpm:lts-latest AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install
|
||||
RUN pnpm build
|
||||
|
||||
FROM docker.io/caddy:alpine
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
COPY --from=builder /build/dist/. .
|
||||
COPY Caddyfile .
|
||||
|
||||
CMD ["caddy", "run"]
|
||||
8
LICENSE
Normal file
@@ -0,0 +1,8 @@
|
||||
Copyright 2024 Zephyruso
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
97
README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# zashboard
|
||||
|
||||
<p align="center">
|
||||
<img src="./readme/pc.png" height="300">
|
||||
<img src="./readme/mobile.png" height="300">
|
||||
</p>
|
||||
|
||||
## **Requirement**
|
||||
|
||||
Browser support
|
||||
|
||||
- Chrome 111 (released March 2023)
|
||||
- Firefox 128 (released July 2024)
|
||||
- Safari 16.4 (released March 2023)
|
||||
- Not supported on iOS 16.4 jailbroken version.
|
||||
|
||||
## **Online**
|
||||
|
||||
You can access the online zashboard at the following link:
|
||||
|
||||
- [Online zashboard](http://board.zash.run.place)
|
||||
|
||||
## **Download**
|
||||
|
||||
You can download the zashboard files here:
|
||||
|
||||
release:
|
||||
|
||||
- [dist.zip (7.81 MB)](https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip) – Includes better font-loading experience.
|
||||
- [dist-no-fonts.zip (1.44 MB)](https://github.com/Zephyruso/zashboard/releases/latest/download/dist-no-fonts.zip) – No fonts included, uses system fonts only.
|
||||
- [dist-cdn-fonts.zip (1.44 MB)](https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip) – Fonts loaded from unpkg.com, If you have trouble connecting to unpkg.com, **you may experience slow page loading**.
|
||||
- [dist-firasans-only.zip (1.67 MB)](https://github.com/Zephyruso/zashboard/releases/latest/download/dist-firasans-only.zip) – Only with FiraSans Font
|
||||
- [dist-misans-only.zip (3.54 MB)](https://github.com/Zephyruso/zashboard/releases/latest/download/dist-misans-only.zip) – Only with MiSans Font
|
||||
- [dist-pingfang-only.zip (3.25 MB)](https://github.com/Zephyruso/zashboard/releases/latest/download/dist-pingfang-only.zip) – Only with PingFang Font
|
||||
- [dist-sarasa-only.zip (3.67 MB)](https://github.com/Zephyruso/zashboard/releases/latest/download/dist-sarasa-only.zip) – Only with Sarasa Font
|
||||
|
||||
dev:
|
||||
|
||||
- [gh-pages.zip (7.81 MB)](https://github.com/Zephyruso/zashboard/archive/refs/heads/gh-pages.zip)
|
||||
- [gh-pages-no-fonts.zip (1.44 MB)](https://github.com/Zephyruso/zashboard/archive/refs/heads/gh-pages-no-fonts.zip)
|
||||
- [gh-pages-cdn-fonts.zip (1.44 MB)](https://github.com/Zephyruso/zashboard/archive/refs/heads/gh-pages-cdn-fonts.zip)
|
||||
- [gh-pages-firasans-only.zip (1.67 MB)](https://github.com/Zephyruso/zashboard/archive/refs/heads/gh-pages-firasans-only.zip)
|
||||
- [gh-pages-misans-only.zip (3.54 MB)](https://github.com/Zephyruso/zashboard/archive/refs/heads/gh-pages-misans-only.zip)
|
||||
- [gh-pages-pingfang-only.zip (3.25 MB)](https://github.com/Zephyruso/zashboard/archive/refs/heads/gh-pages-pingfang-only.zip)
|
||||
- [gh-pages-sarasa-only.zip (3.67 MB)](https://github.com/Zephyruso/zashboard/archive/refs/heads/gh-pages-sarasa-only.zip)
|
||||
|
||||
## **Docker Setup**
|
||||
|
||||
To run zashboard via Docker, use the following command:
|
||||
|
||||
```
|
||||
docker run -d -p 80:80 ghcr.io/zephyruso/zashboard:latest
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
1. The connection table can be dragged with the left mouse button, and right-clicking can copy cell content.
|
||||
2. Right-clicking on a node / node group card will perform a speedtest for the node / node group.
|
||||
3. The proxy group sorting is based on the node order in the GLOBAL group. In Mihomo, it follows the configuration file order, while in sing-box, route.final is placed first, with the rest following the configuration file order. If you need custom ordering, you can specify the order by overriding the GLOBAL group.
|
||||
4. The dashboard supports PWA (Progressive Web App), which can provide a native app-like experience on mobile devices through "Add to Home Screen".
|
||||
5. The dashboard's upgrade button and auto-upgrade functionality require proper configuration of the core's UI download path ([mihomo](https://wiki.metacubex.one/config/general/#_9) | [sing-box](https://sing-box.sagernet.org/configuration/experimental/clash-api/#external_ui_download_url)), otherwise clicking update may result in updating to the core's default panel.
|
||||
|
||||
## 提示
|
||||
|
||||
1. 连接表格可被鼠标左键拖动,右键可复制单元格内容。
|
||||
2. 右键点击节点/节点组卡片可对节点/节点组进行测速。
|
||||
3. 面板的节点组排序是根据GLOBAL组中的节点顺序排序的,在Mihomo中会是按配置文件的顺序,在sing-box中会把route.final放到第一位,其余按照配置文件顺序,如果你需要自定义顺序,可通过覆盖GLOBAL组指定顺序
|
||||
4. 面板支持PWA(Progressive Web App),可以在移动设备上通过"添加到主屏幕"获得类原生app的体验
|
||||
5. 面板的更新按钮和自动更新功能需要正确的配置核心的ui下载路径 ([mihomo](https://wiki.metacubex.one/config/general/#_9) | [sing-box](https://sing-box.sagernet.org/configuration/experimental/clash-api/#external_ui_download_url)), 否则可能会在点击更新后更新为核心默认面板
|
||||
|
||||
## URL params format
|
||||
|
||||
#### basic example
|
||||
|
||||
http://host:port/#/setup?hostname=ipordomain&port=9090&secret=123456
|
||||
|
||||
1. **`http` / `https`**
|
||||
- Determines the protocol (`http` or `https`).
|
||||
- Default: current page protocol
|
||||
|
||||
2. **`hostname`**
|
||||
- The Clash API's IP or domain.
|
||||
|
||||
3. **`port`**
|
||||
- The Clash API port.
|
||||
|
||||
4. **`secondaryPath`**
|
||||
- Optional path appended to the base URL.
|
||||
- Default: An empty string.
|
||||
|
||||
5. **`secret`**
|
||||
- Password for authentication.
|
||||
|
||||
6. **`disableUpgradeCore`**
|
||||
- Set '1' or 'true' to hide upgrade core button
|
||||
|
||||
### I code just for fun, not for money. If you really want to donate, please consider donating to [UNICEF](https://www.unicef.org/) to help hungry children.
|
||||
31
eslint.config.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import vueTsEslintConfig from '@vue/eslint-config-typescript'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
|
||||
export default [
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'app/files-to-ignore',
|
||||
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
|
||||
},
|
||||
|
||||
...pluginVue.configs['flat/essential'],
|
||||
...vueTsEslintConfig({
|
||||
rules: {
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/multiline-html-element-content-newline': 'off',
|
||||
'vue/max-attributes-per-line': [
|
||||
'error',
|
||||
{
|
||||
'singleline': 1,
|
||||
'multiline': 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
skipFormatting,
|
||||
]
|
||||
56
index.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>zashboard</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<meta
|
||||
name="description"
|
||||
content="A dashboard using clash API"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="./apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="./favicon.ico"
|
||||
sizes="48x48"
|
||||
/>
|
||||
<link
|
||||
id="favicon"
|
||||
rel="icon"
|
||||
href="./favicon.svg"
|
||||
sizes="any"
|
||||
type="image/svg+xml"
|
||||
/>
|
||||
<meta
|
||||
name="theme-color"
|
||||
content="#FFFFFF"
|
||||
/>
|
||||
</head>
|
||||
<body class="h-dvh w-screen">
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
;(function () {
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const favicon = document.getElementById('favicon')
|
||||
const setFavicon = (isDark) => {
|
||||
favicon.href = isDark ? './favicon-dark.svg' : './favicon.svg'
|
||||
}
|
||||
|
||||
media.addEventListener('change', () => {
|
||||
setFavicon(media.matches)
|
||||
})
|
||||
setFavicon(media.matches)
|
||||
})()
|
||||
</script>
|
||||
<script
|
||||
type="module"
|
||||
src="/src/main.ts"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
80
package.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "zashboard",
|
||||
"version": "2.6.0",
|
||||
"description": "A Dashboard Using Clash API",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:cdn-fonts": "vite build --mode cdn-fonts",
|
||||
"build:firasans-only": "vite build --mode FiraSans",
|
||||
"build:misans-only": "vite build --mode MiSans",
|
||||
"build:no-fonts": "vite build --mode SystemUI",
|
||||
"build:pingfang-only": "vite build --mode PingFang",
|
||||
"build:sarasa-only": "vite build --mode SarasaUi",
|
||||
"dev": "vite",
|
||||
"format": "prettier --write src/",
|
||||
"lint": "eslint . --fix",
|
||||
"prepare": "husky",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --build --force"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eslint/plugin-kit": "^0.5.0",
|
||||
"@fontsource/fira-sans": "^5.2.7",
|
||||
"@heroicons/vue": "^2.2.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@tanstack/vue-virtual": "^3.13.13",
|
||||
"@types/reconnectingwebsocket": "^1.0.10",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"axios": "^1.13.2",
|
||||
"countup.js": "^2.9.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"echarts": "^6.0.0",
|
||||
"ipaddr.js": "^2.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"misans": "^4.1.0",
|
||||
"p-limit": "^7.2.0",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"reconnectingwebsocket": "^1.0.0",
|
||||
"sort-package-json": "^3.6.0",
|
||||
"subsetted-fonts": "^1.0.4",
|
||||
"tippy.js": "^6.3.7",
|
||||
"uuid": "^13.0.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vue": "^3.5.26",
|
||||
"vue-i18n": "^11.2.7",
|
||||
"vue-json-pretty": "^2.6.0",
|
||||
"vue-router": "^4.6.4",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tsconfig/node22": "^22.0.5",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/node": "^25.0.3",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.2",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"daisyui": "^5.5.14",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-conditionals": "^2.1.0",
|
||||
"postcss-for": "^2.1.1",
|
||||
"prettier": "^3.7.4",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.0",
|
||||
"vue-tsc": "^3.2.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.15.0"
|
||||
}
|
||||
8576
pnpm-lock.yaml
generated
Normal file
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'postcss-for': {},
|
||||
'postcss-conditionals': {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
1
public/favicon-dark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="size-6" viewBox="0 0 24 24" style="filter: invert(1);"><path fill-rule="evenodd" d="M11.622 1.602a.75.75 0 0 1 .756 0l2.25 1.313a.75.75 0 0 1-.756 1.295L12 3.118 10.128 4.21a.75.75 0 1 1-.756-1.295l2.25-1.313ZM5.898 5.81a.75.75 0 0 1-.27 1.025l-1.14.665 1.14.665a.75.75 0 1 1-.756 1.295L3.75 8.806v.944a.75.75 0 0 1-1.5 0V7.5a.75.75 0 0 1 .372-.648l2.25-1.312a.75.75 0 0 1 1.026.27Zm12.204 0a.75.75 0 0 1 1.026-.27l2.25 1.312a.75.75 0 0 1 .372.648v2.25a.75.75 0 0 1-1.5 0v-.944l-1.122.654a.75.75 0 1 1-.756-1.295l1.14-.665-1.14-.665a.75.75 0 0 1-.27-1.025Zm-9 5.25a.75.75 0 0 1 1.026-.27L12 11.882l1.872-1.092a.75.75 0 1 1 .756 1.295l-1.878 1.096V15a.75.75 0 0 1-1.5 0v-1.82l-1.878-1.095a.75.75 0 0 1-.27-1.025ZM3 13.5a.75.75 0 0 1 .75.75v1.82l1.878 1.095a.75.75 0 1 1-.756 1.295l-2.25-1.312a.75.75 0 0 1-.372-.648v-2.25A.75.75 0 0 1 3 13.5Zm18 0a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-.372.648l-2.25 1.312a.75.75 0 1 1-.756-1.295l1.878-1.096V14.25a.75.75 0 0 1 .75-.75Zm-9 5.25a.75.75 0 0 1 .75.75v.944l1.122-.654a.75.75 0 1 1 .756 1.295l-2.25 1.313a.75.75 0 0 1-.756 0l-2.25-1.313a.75.75 0 1 1 .756-1.295l1.122.654V19.5a.75.75 0 0 1 .75-.75Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
1
public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="size-6" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M11.622 1.602a.75.75 0 0 1 .756 0l2.25 1.313a.75.75 0 0 1-.756 1.295L12 3.118 10.128 4.21a.75.75 0 1 1-.756-1.295l2.25-1.313ZM5.898 5.81a.75.75 0 0 1-.27 1.025l-1.14.665 1.14.665a.75.75 0 1 1-.756 1.295L3.75 8.806v.944a.75.75 0 0 1-1.5 0V7.5a.75.75 0 0 1 .372-.648l2.25-1.312a.75.75 0 0 1 1.026.27Zm12.204 0a.75.75 0 0 1 1.026-.27l2.25 1.312a.75.75 0 0 1 .372.648v2.25a.75.75 0 0 1-1.5 0v-.944l-1.122.654a.75.75 0 1 1-.756-1.295l1.14-.665-1.14-.665a.75.75 0 0 1-.27-1.025Zm-9 5.25a.75.75 0 0 1 1.026-.27L12 11.882l1.872-1.092a.75.75 0 1 1 .756 1.295l-1.878 1.096V15a.75.75 0 0 1-1.5 0v-1.82l-1.878-1.095a.75.75 0 0 1-.27-1.025ZM3 13.5a.75.75 0 0 1 .75.75v1.82l1.878 1.095a.75.75 0 1 1-.756 1.295l-2.25-1.312a.75.75 0 0 1-.372-.648v-2.25A.75.75 0 0 1 3 13.5Zm18 0a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-.372.648l-2.25 1.312a.75.75 0 1 1-.756-1.295l1.878-1.096V14.25a.75.75 0 0 1 .75-.75Zm-9 5.25a.75.75 0 0 1 .75.75v.944l1.122-.654a.75.75 0 1 1 .756 1.295l-2.25 1.313a.75.75 0 0 1-.756 0l-2.25-1.313a.75.75 0 1 1 .756-1.295l1.122.654V19.5a.75.75 0 0 1 .75-.75Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
public/icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-6">
|
||||
<path fill-rule="evenodd" d="M11.622 1.602a.75.75 0 0 1 .756 0l2.25 1.313a.75.75 0 0 1-.756 1.295L12 3.118 10.128 4.21a.75.75 0 1 1-.756-1.295l2.25-1.313ZM5.898 5.81a.75.75 0 0 1-.27 1.025l-1.14.665 1.14.665a.75.75 0 1 1-.756 1.295L3.75 8.806v.944a.75.75 0 0 1-1.5 0V7.5a.75.75 0 0 1 .372-.648l2.25-1.312a.75.75 0 0 1 1.026.27Zm12.204 0a.75.75 0 0 1 1.026-.27l2.25 1.312a.75.75 0 0 1 .372.648v2.25a.75.75 0 0 1-1.5 0v-.944l-1.122.654a.75.75 0 1 1-.756-1.295l1.14-.665-1.14-.665a.75.75 0 0 1-.27-1.025Zm-9 5.25a.75.75 0 0 1 1.026-.27L12 11.882l1.872-1.092a.75.75 0 1 1 .756 1.295l-1.878 1.096V15a.75.75 0 0 1-1.5 0v-1.82l-1.878-1.095a.75.75 0 0 1-.27-1.025ZM3 13.5a.75.75 0 0 1 .75.75v1.82l1.878 1.095a.75.75 0 1 1-.756 1.295l-2.25-1.312a.75.75 0 0 1-.372-.648v-2.25A.75.75 0 0 1 3 13.5Zm18 0a.75.75 0 0 1 .75.75v2.25a.75.75 0 0 1-.372.648l-2.25 1.312a.75.75 0 1 1-.756-1.295l1.878-1.096V14.25a.75.75 0 0 1 .75-.75Zm-9 5.25a.75.75 0 0 1 .75.75v.944l1.122-.654a.75.75 0 1 1 .756 1.295l-2.25 1.313a.75.75 0 0 1-.756 0l-2.25-1.313a.75.75 0 1 1 .756-1.295l1.122.654V19.5a.75.75 0 0 1 .75-.75Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
public/pwa-maskable-192x192.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/pwa-maskable-512x512.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
readme/mobile.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
readme/pc.png
Normal file
|
After Width: | Height: | Size: 611 KiB |
148
src/App.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, type Ref, watch } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useKeyboard } from './composables/keyboard'
|
||||
import { EMOJIS, FONTS } from './constant'
|
||||
import { autoImportSettings, importSettingsFromUrl } from './helper/autoImportSettings'
|
||||
import { backgroundImage } from './helper/indexeddb'
|
||||
import { initNotification } from './helper/notification'
|
||||
import { getBackendFromUrl, isPreferredDark } from './helper/utils'
|
||||
import {
|
||||
blurIntensity,
|
||||
dashboardTransparent,
|
||||
disablePullToRefresh,
|
||||
emoji,
|
||||
font,
|
||||
theme,
|
||||
} from './store/settings'
|
||||
import { activeUuid, backendList } from './store/setup'
|
||||
import type { Backend } from './types'
|
||||
|
||||
const app = ref<HTMLElement>()
|
||||
const toast = ref<HTMLElement>()
|
||||
|
||||
initNotification(toast as Ref<HTMLElement>)
|
||||
|
||||
// 字体类名映射表
|
||||
const FONT_CLASS_MAP = {
|
||||
[EMOJIS.TWEMOJI]: {
|
||||
[FONTS.MI_SANS]: 'font-MiSans-Twemoji',
|
||||
[FONTS.SARASA_UI]: 'font-SarasaUI-Twemoji',
|
||||
[FONTS.PING_FANG]: 'font-PingFang-Twemoji',
|
||||
[FONTS.FIRA_SANS]: 'font-FiraSans-Twemoji',
|
||||
[FONTS.SYSTEM_UI]: 'font-SystemUI-Twemoji',
|
||||
},
|
||||
[EMOJIS.NOTO_COLOR_EMOJI]: {
|
||||
[FONTS.MI_SANS]: 'font-MiSans-NotoEmoji',
|
||||
[FONTS.SARASA_UI]: 'font-SarasaUI-NotoEmoji',
|
||||
[FONTS.PING_FANG]: 'font-PingFang-NotoEmoji',
|
||||
[FONTS.FIRA_SANS]: 'font-FiraSans-NotoEmoji',
|
||||
[FONTS.SYSTEM_UI]: 'font-SystemUI-NotoEmoji',
|
||||
},
|
||||
} as const
|
||||
|
||||
const fontClassName = computed(() => {
|
||||
return (
|
||||
FONT_CLASS_MAP[emoji.value]?.[font.value] || FONT_CLASS_MAP[EMOJIS.TWEMOJI][FONTS.SYSTEM_UI]
|
||||
)
|
||||
})
|
||||
|
||||
const setThemeColor = () => {
|
||||
const themeColor = getComputedStyle(app.value!).getPropertyValue('background-color').trim()
|
||||
const metaThemeColor = document.querySelector('meta[name="theme-color"]')
|
||||
if (metaThemeColor) {
|
||||
metaThemeColor.setAttribute('content', themeColor)
|
||||
}
|
||||
}
|
||||
|
||||
watch(isPreferredDark, setThemeColor)
|
||||
|
||||
watch(
|
||||
disablePullToRefresh,
|
||||
() => {
|
||||
const body = document.body
|
||||
if (disablePullToRefresh.value) {
|
||||
body.style.overscrollBehavior = 'none'
|
||||
body.style.overflow = 'hidden'
|
||||
} else {
|
||||
body.style.overscrollBehavior = ''
|
||||
body.style.overflow = ''
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
)
|
||||
|
||||
const isSameBackend = (b1: Omit<Backend, 'uuid'>, b2: Omit<Backend, 'uuid'>) => {
|
||||
return (
|
||||
b1.host === b2.host &&
|
||||
b1.port === b2.port &&
|
||||
b1.password === b2.password &&
|
||||
b1.protocol === b2.protocol &&
|
||||
b1.secondaryPath === b2.secondaryPath
|
||||
)
|
||||
}
|
||||
|
||||
const autoSwitchToURLBackendIfExists = () => {
|
||||
const backend = getBackendFromUrl()
|
||||
|
||||
if (backend) {
|
||||
for (const b of backendList.value) {
|
||||
if (isSameBackend(b, backend)) {
|
||||
activeUuid.value = b.uuid
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
autoSwitchToURLBackendIfExists()
|
||||
|
||||
onMounted(() => {
|
||||
if (autoImportSettings.value) {
|
||||
importSettingsFromUrl()
|
||||
}
|
||||
watch(
|
||||
theme,
|
||||
() => {
|
||||
document.body.setAttribute('data-theme', theme.value)
|
||||
setThemeColor()
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const blurClass = computed(() => {
|
||||
if (!backgroundImage.value || blurIntensity.value === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return `blur-intensity-${blurIntensity.value}`
|
||||
})
|
||||
|
||||
useKeyboard()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="app"
|
||||
id="app-content"
|
||||
:class="[
|
||||
'bg-base-100 flex h-dvh w-screen overflow-hidden',
|
||||
fontClassName,
|
||||
backgroundImage &&
|
||||
`custom-background-${dashboardTransparent} custom-background bg-cover bg-center`,
|
||||
blurClass,
|
||||
]"
|
||||
:style="backgroundImage"
|
||||
>
|
||||
<RouterView />
|
||||
<div
|
||||
ref="toast"
|
||||
class="toast-sm toast toast-end toast-top z-9999 max-w-80 text-sm md:max-w-96 md:translate-y-8"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
206
src/api/geoip.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { IP_INFO_API } from '@/constant'
|
||||
import { IPInfoAPI } from '@/store/settings'
|
||||
|
||||
export interface IPInfo {
|
||||
ip: string
|
||||
country: string
|
||||
region: string
|
||||
city: string
|
||||
asn: string
|
||||
organization: string
|
||||
}
|
||||
|
||||
// china
|
||||
export const getIPFromIpipnetAPI = async () => {
|
||||
const response = await fetch('https://myip.ipip.net/json?t=' + Date.now())
|
||||
|
||||
return (await response.json()) as {
|
||||
data: {
|
||||
ip: string
|
||||
location: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// global
|
||||
const getIPFromIpsbAPI = async (ip = '') => {
|
||||
const response = await fetch(
|
||||
'https://api.ip.sb/geoip' + (ip ? `/${ip}` : '') + '?t=' + Date.now(),
|
||||
)
|
||||
|
||||
return (await response.json()) as {
|
||||
organization: string
|
||||
longitude: number
|
||||
city: string
|
||||
region: string
|
||||
timezone: string
|
||||
isp: string
|
||||
offset: number
|
||||
asn: number
|
||||
asn_organization: string
|
||||
country: string
|
||||
ip: string
|
||||
latitude: number
|
||||
postal_code: string
|
||||
continent_code: string
|
||||
country_code: string
|
||||
region_code: string
|
||||
}
|
||||
}
|
||||
|
||||
const getIPFromIPWhoisAPI = async (ip = '') => {
|
||||
const response = await fetch('https://ipwho.is' + (ip ? `/${ip}` : '') + '?t=' + Date.now())
|
||||
|
||||
return (await response.json()) as {
|
||||
ip: string
|
||||
success: boolean
|
||||
type: string
|
||||
continent: string
|
||||
continent_code: string
|
||||
country: string
|
||||
country_code: string
|
||||
region: string
|
||||
region_code: string
|
||||
city: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
is_eu: boolean
|
||||
postal: string
|
||||
calling_code: string
|
||||
capital: string
|
||||
borders: string
|
||||
flag: {
|
||||
img: string
|
||||
emoji: string
|
||||
emoji_unicode: string
|
||||
}
|
||||
connection: {
|
||||
asn: number
|
||||
org: string
|
||||
isp: string
|
||||
domain: string
|
||||
}
|
||||
timezone: {
|
||||
id: string
|
||||
abbr: string
|
||||
is_dst: boolean
|
||||
offset: number
|
||||
utc: string
|
||||
current_time: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getIPFromIPapiisAPI = async (ip = '') => {
|
||||
const response = await fetch(
|
||||
'https://api.ipapi.is' + (ip ? `/?q=${ip}` : '') + (ip ? '&' : '?') + 't=' + Date.now(),
|
||||
)
|
||||
|
||||
return (await response.json()) as {
|
||||
ip: string
|
||||
rir: string
|
||||
is_bogon: boolean
|
||||
is_mobile: boolean
|
||||
is_satellite: boolean
|
||||
is_crawler: boolean
|
||||
is_datacenter: boolean
|
||||
is_tor: boolean
|
||||
is_proxy: boolean
|
||||
is_vpn: boolean
|
||||
is_abuser: boolean
|
||||
datacenter: {
|
||||
datacenter: string
|
||||
network: string
|
||||
region: string
|
||||
service: string
|
||||
network_border_group: string
|
||||
}
|
||||
company: {
|
||||
name: string
|
||||
abuser_score: string
|
||||
domain: string
|
||||
type: string
|
||||
network: string
|
||||
whois: string
|
||||
}
|
||||
abuse: {
|
||||
name: string
|
||||
address: string
|
||||
email: string
|
||||
phone: string
|
||||
}
|
||||
asn: {
|
||||
asn: number
|
||||
abuser_score: string
|
||||
route: string
|
||||
descr: string
|
||||
country: string
|
||||
active: boolean
|
||||
org: string
|
||||
domain: string
|
||||
abuse: string
|
||||
type: string
|
||||
created: string
|
||||
updated: string
|
||||
rir: string
|
||||
whois: string
|
||||
}
|
||||
location: {
|
||||
is_eu_member: boolean
|
||||
calling_code: string
|
||||
currency_code: string
|
||||
continent: string
|
||||
country: string
|
||||
country_code: string
|
||||
state: string
|
||||
city: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
zip: string
|
||||
timezone: string
|
||||
local_time: string
|
||||
local_time_unix: number
|
||||
is_dst: boolean
|
||||
}
|
||||
elapsed_ms: number
|
||||
}
|
||||
}
|
||||
|
||||
export const getIPInfo = async (ip = ''): Promise<IPInfo> => {
|
||||
switch (IPInfoAPI.value) {
|
||||
case IP_INFO_API.IPAPI:
|
||||
const ipapi = await getIPFromIPapiisAPI(ip)
|
||||
|
||||
return {
|
||||
ip: ipapi.ip,
|
||||
country: ipapi.location.country,
|
||||
region: ipapi.location.state,
|
||||
city: ipapi.location.city,
|
||||
asn: ipapi.asn.asn.toString(),
|
||||
organization: ipapi.asn.org,
|
||||
}
|
||||
case IP_INFO_API.IPWHOIS:
|
||||
const ipwhois = await getIPFromIPWhoisAPI(ip)
|
||||
|
||||
return {
|
||||
ip: ipwhois.ip,
|
||||
region: ipwhois.region,
|
||||
country: ipwhois.country,
|
||||
city: ipwhois.city,
|
||||
asn: ipwhois.connection.asn.toString(),
|
||||
organization: ipwhois.connection.org,
|
||||
}
|
||||
case IP_INFO_API.IPSB:
|
||||
default:
|
||||
const ipsb = await getIPFromIpsbAPI(ip)
|
||||
|
||||
return {
|
||||
ip: ipsb.ip,
|
||||
country: ipsb.country,
|
||||
region: ipsb.region,
|
||||
city: ipsb.city,
|
||||
asn: ipsb.asn.toString(),
|
||||
organization: ipsb.organization,
|
||||
}
|
||||
}
|
||||
}
|
||||
390
src/api/index.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { ROUTE_NAME } from '@/constant'
|
||||
import { showNotification } from '@/helper/notification'
|
||||
import { getUrlFromBackend } from '@/helper/utils'
|
||||
import router from '@/router'
|
||||
import { autoUpgradeCore, checkUpgradeCore } from '@/store/settings'
|
||||
import { activeBackend, activeUuid } from '@/store/setup'
|
||||
import type {
|
||||
Backend,
|
||||
Config,
|
||||
DNSQuery,
|
||||
NodeRank,
|
||||
Proxy,
|
||||
ProxyProvider,
|
||||
Rule,
|
||||
RuleProvider,
|
||||
} from '@/types'
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { debounce } from 'lodash'
|
||||
import ReconnectingWebSocket from 'reconnectingwebsocket'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
axios.interceptors.request.use((config) => {
|
||||
config.baseURL = getUrlFromBackend(activeBackend.value!)
|
||||
config.headers['Authorization'] = 'Bearer ' + activeBackend.value?.password
|
||||
return config
|
||||
})
|
||||
|
||||
const ignoreNotificationUrls = ['/delay', '/weights']
|
||||
|
||||
axios.interceptors.response.use(
|
||||
null,
|
||||
(
|
||||
error: AxiosError<{
|
||||
message: string
|
||||
}>,
|
||||
) => {
|
||||
if (error.status === 401 && activeUuid.value) {
|
||||
const currentBackendUuid = activeUuid.value
|
||||
activeUuid.value = null
|
||||
router.push({
|
||||
name: ROUTE_NAME.setup,
|
||||
query: { editBackend: currentBackendUuid },
|
||||
})
|
||||
nextTick(() => {
|
||||
showNotification({ content: 'unauthorizedTip' })
|
||||
})
|
||||
} else if (!ignoreNotificationUrls.some((url) => error.config?.url?.endsWith(url))) {
|
||||
const errorMessage = error.response?.data?.message || error.message
|
||||
|
||||
showNotification({
|
||||
key: errorMessage,
|
||||
content: `${error.config?.url} \n${errorMessage}`,
|
||||
type: 'alert-error',
|
||||
})
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
return error
|
||||
},
|
||||
)
|
||||
|
||||
export const version = ref()
|
||||
export const isCoreUpdateAvailable = ref(false)
|
||||
export const fetchVersionAPI = () => {
|
||||
return axios.get<{ version: string }>('/version')
|
||||
}
|
||||
export const isSingBox = computed(() => version.value?.includes('sing-box'))
|
||||
export const zashboardVersion = ref(__APP_VERSION__)
|
||||
|
||||
watch(
|
||||
activeBackend,
|
||||
async (val) => {
|
||||
if (val) {
|
||||
const { data } = await fetchVersionAPI()
|
||||
|
||||
version.value = data?.version || ''
|
||||
if (isSingBox.value || !checkUpgradeCore.value || activeBackend.value?.disableUpgradeCore)
|
||||
return
|
||||
isCoreUpdateAvailable.value = await fetchBackendUpdateAvailableAPI()
|
||||
|
||||
if (isCoreUpdateAvailable.value && autoUpgradeCore.value) {
|
||||
upgradeCoreAPI('auto')
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
export const fetchProxiesAPI = () => {
|
||||
return axios.get<{ proxies: Record<string, Proxy> }>('/proxies')
|
||||
}
|
||||
|
||||
export const selectProxyAPI = (proxyGroup: string, name: string) => {
|
||||
return axios.put(`/proxies/${encodeURIComponent(proxyGroup)}`, { name })
|
||||
}
|
||||
|
||||
export const deleteFixedProxyAPI = (proxyGroup: string) => {
|
||||
return axios.delete(`/proxies/${encodeURIComponent(proxyGroup)}`)
|
||||
}
|
||||
|
||||
export const fetchProxyLatencyAPI = (proxyName: string, url: string, timeout: number) => {
|
||||
return axios.get<{ delay: number }>(`/proxies/${encodeURIComponent(proxyName)}/delay`, {
|
||||
params: {
|
||||
url,
|
||||
timeout,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const fetchProxyGroupLatencyAPI = (proxyName: string, url: string, timeout: number) => {
|
||||
return axios.get<Record<string, number>>(`/group/${encodeURIComponent(proxyName)}/delay`, {
|
||||
params: {
|
||||
url,
|
||||
timeout,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const fetchSmartWeightsAPI = () => {
|
||||
return axios.get<{
|
||||
message: string
|
||||
weights: Record<string, NodeRank[]>
|
||||
}>(`/group/weights`)
|
||||
}
|
||||
|
||||
// deprecated
|
||||
export const fetchSmartGroupWeightsAPI = (proxyName: string) => {
|
||||
return axios.get<{
|
||||
message: string
|
||||
weights: NodeRank[]
|
||||
}>(`/group/${encodeURIComponent(proxyName)}/weights`)
|
||||
}
|
||||
|
||||
export const flushSmartGroupWeightsAPI = () => {
|
||||
return axios.post(`/cache/smart/flush`)
|
||||
}
|
||||
|
||||
export const fetchProxyProviderAPI = () => {
|
||||
return axios.get<{ providers: Record<string, ProxyProvider> }>('/providers/proxies')
|
||||
}
|
||||
|
||||
export const updateProxyProviderAPI = (name: string) => {
|
||||
return axios.put(`/providers/proxies/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
export const proxyProviderHealthCheckAPI = (name: string) => {
|
||||
return axios.get<Record<string, number>>(
|
||||
`/providers/proxies/${encodeURIComponent(name)}/healthcheck`,
|
||||
{
|
||||
timeout: 15000,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export const fetchRulesAPI = () => {
|
||||
return axios.get<{ rules: Rule[] }>('/rules')
|
||||
}
|
||||
|
||||
export const toggleRuleDisabledAPI = (data: Record<number, boolean>) => {
|
||||
return axios.patch(`/rules/disable`, data)
|
||||
}
|
||||
|
||||
export const toggleRuleDisabledSingBoxAPI = (uuid: string) => {
|
||||
return axios.put(`/rules/${encodeURIComponent(uuid)}`)
|
||||
}
|
||||
|
||||
export const fetchRuleProvidersAPI = () => {
|
||||
return axios.get<{ providers: Record<string, RuleProvider> }>('/providers/rules')
|
||||
}
|
||||
|
||||
export const updateRuleProviderAPI = (name: string) => {
|
||||
return axios.put(`/providers/rules/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
export const blockConnectionByIdAPI = (id: string) => {
|
||||
return axios.delete(`/connections/smart/${id}`)
|
||||
}
|
||||
|
||||
export const disconnectByIdAPI = (id: string) => {
|
||||
return axios.delete(`/connections/${id}`)
|
||||
}
|
||||
|
||||
export const disconnectAllAPI = () => {
|
||||
return axios.delete('/connections')
|
||||
}
|
||||
|
||||
export const getConfigsAPI = () => {
|
||||
return axios.get<Config>('/configs')
|
||||
}
|
||||
|
||||
export const patchConfigsAPI = (configs: Record<string, string | boolean | object | number>) => {
|
||||
return axios.patch('/configs', configs)
|
||||
}
|
||||
|
||||
export const flushFakeIPAPI = () => {
|
||||
return axios.post('/cache/fakeip/flush')
|
||||
}
|
||||
|
||||
export const flushDNSCacheAPI = () => {
|
||||
return axios.post('/cache/dns/flush')
|
||||
}
|
||||
|
||||
export const reloadConfigsAPI = () => {
|
||||
return axios.put('/configs?reload=true', { path: '', payload: '' })
|
||||
}
|
||||
|
||||
export const upgradeUIAPI = () => {
|
||||
return axios.post('/upgrade/ui')
|
||||
}
|
||||
|
||||
export const updateGeoDataAPI = () => {
|
||||
return axios.post('/configs/geo')
|
||||
}
|
||||
|
||||
export const upgradeCoreAPI = (type: 'release' | 'alpha' | 'auto') => {
|
||||
const url = type === 'auto' ? '/upgrade' : `/upgrade?channel=${type}`
|
||||
|
||||
return axios.post(url)
|
||||
}
|
||||
|
||||
export const restartCoreAPI = () => {
|
||||
return axios.post('/restart')
|
||||
}
|
||||
|
||||
export const queryDNSAPI = (params: { name: string; type: string }) => {
|
||||
return axios.get<DNSQuery>('/dns/query', {
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
const createWebSocket = <T>(url: string, searchParams?: Record<string, string>) => {
|
||||
const backend = activeBackend.value!
|
||||
const resurl = new URL(`${getUrlFromBackend(backend).replace('http', 'ws')}/${url}`)
|
||||
|
||||
resurl.searchParams.append('token', backend?.password || '')
|
||||
|
||||
if (searchParams) {
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
resurl.searchParams.append(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
const data = ref<T>()
|
||||
const websocket = new ReconnectingWebSocket(resurl.toString())
|
||||
|
||||
const close = () => {
|
||||
websocket.close()
|
||||
}
|
||||
|
||||
const messageHandler = ({ data: message }: { data: string }) => {
|
||||
data.value = JSON.parse(message)
|
||||
}
|
||||
|
||||
websocket.onmessage = url === 'logs' ? messageHandler : debounce(messageHandler, 100)
|
||||
|
||||
return {
|
||||
data,
|
||||
close,
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchConnectionsAPI = <T>() => {
|
||||
return createWebSocket<T>('connections')
|
||||
}
|
||||
|
||||
export const fetchLogsAPI = <T>(params: Record<string, string> = {}) => {
|
||||
return createWebSocket<T>('logs', params)
|
||||
}
|
||||
|
||||
export const fetchMemoryAPI = <T>() => {
|
||||
return createWebSocket<T>('memory')
|
||||
}
|
||||
|
||||
export const fetchTrafficAPI = <T>() => {
|
||||
return createWebSocket<T>('traffic')
|
||||
}
|
||||
|
||||
export const isBackendAvailable = async (backend: Backend, timeout: number = 10000) => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${getUrlFromBackend(backend)}/version`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${backend.password}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
const CACHE_DURATION = 1000 * 60 * 60
|
||||
|
||||
interface CacheEntry<T> {
|
||||
timestamp: number
|
||||
version: string
|
||||
data: T
|
||||
}
|
||||
|
||||
async function fetchWithLocalCache<T>(url: string, version: string): Promise<T> {
|
||||
const cacheKey = 'cache/' + url
|
||||
const cacheRaw = localStorage.getItem(cacheKey)
|
||||
|
||||
if (cacheRaw) {
|
||||
try {
|
||||
const cache: CacheEntry<T> = JSON.parse(cacheRaw)
|
||||
const now = Date.now()
|
||||
|
||||
if (now - cache.timestamp < CACHE_DURATION && cache.version === version) {
|
||||
return cache.data
|
||||
} else {
|
||||
localStorage.removeItem(cacheKey)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse cache for', url, e)
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fetch failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data: T = await response.json()
|
||||
const newCache: CacheEntry<T> = {
|
||||
timestamp: Date.now(),
|
||||
version,
|
||||
data,
|
||||
}
|
||||
|
||||
localStorage.setItem(cacheKey, JSON.stringify(newCache))
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchIsUIUpdateAvailable = async () => {
|
||||
const { tag_name } = await fetchWithLocalCache<{ tag_name: string }>(
|
||||
'https://api.github.com/repos/Zephyruso/zashboard/releases/latest',
|
||||
zashboardVersion.value,
|
||||
)
|
||||
|
||||
return Boolean(tag_name && tag_name !== `v${zashboardVersion.value}`)
|
||||
}
|
||||
|
||||
const check = async (url: string, versionNumber: string) => {
|
||||
const { assets } = await fetchWithLocalCache<{ assets: { name: string }[] }>(url, versionNumber)
|
||||
const alreadyLatest = assets.some(({ name }) => name.includes(versionNumber))
|
||||
|
||||
return !alreadyLatest
|
||||
}
|
||||
|
||||
export const fetchBackendUpdateAvailableAPI = async () => {
|
||||
const match = /(alpha-smart|alpha|beta|meta)-?(\w+)/.exec(version.value)
|
||||
|
||||
if (!match) {
|
||||
const { tag_name } = await fetchWithLocalCache<{ tag_name: string }>(
|
||||
'https://api.github.com/repos/MetaCubeX/mihomo/releases/latest',
|
||||
version.value,
|
||||
)
|
||||
|
||||
return Boolean(tag_name && !tag_name.endsWith(version.value))
|
||||
}
|
||||
|
||||
const channel = match[1],
|
||||
versionNumber = match[2]
|
||||
|
||||
if (channel === 'meta')
|
||||
return await check(
|
||||
'https://api.github.com/repos/MetaCubeX/mihomo/releases/latest',
|
||||
versionNumber,
|
||||
)
|
||||
if (channel === 'alpha')
|
||||
return await check(
|
||||
'https://api.github.com/repos/MetaCubeX/mihomo/releases/tags/Prerelease-Alpha',
|
||||
versionNumber,
|
||||
)
|
||||
if (channel === 'alpha-smart')
|
||||
return await check(
|
||||
'https://api.github.com/repos/vernesong/mihomo/releases/tags/Prerelease-Alpha',
|
||||
versionNumber,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
37
src/api/latency.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
const getLatencyFromUrlAPI = (url: string) => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const startTime = performance.now()
|
||||
const img = document.createElement('img')
|
||||
img.src = url + '?_=' + new Date().getTime()
|
||||
img.style.display = 'none'
|
||||
img.onload = () => {
|
||||
const endTime = performance.now()
|
||||
img.remove()
|
||||
|
||||
resolve(endTime - startTime)
|
||||
}
|
||||
img.onerror = () => {
|
||||
img.remove()
|
||||
|
||||
resolve(0)
|
||||
}
|
||||
|
||||
document.body.appendChild(img)
|
||||
})
|
||||
}
|
||||
|
||||
export const getCloudflareLatencyAPI = () => {
|
||||
return getLatencyFromUrlAPI('https://www.cloudflare.com/favicon.ico')
|
||||
}
|
||||
|
||||
export const getYouTubeLatencyAPI = () => {
|
||||
return getLatencyFromUrlAPI('https://yt3.ggpht.com/favicon.ico')
|
||||
}
|
||||
|
||||
export const getGithubLatencyAPI = () => {
|
||||
return getLatencyFromUrlAPI('https://github.githubassets.com/favicon.ico')
|
||||
}
|
||||
|
||||
export const getBaiduLatencyAPI = () => {
|
||||
return getLatencyFromUrlAPI('https://apps.bdimg.com/favicon.ico')
|
||||
}
|
||||
BIN
src/assets/NotoColorEmoji-flagsonly.ttf
Normal file
BIN
src/assets/TwemojiMozilla-flags.woff2
Normal file
33
src/assets/load-fonts.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const loadFonts = () => {
|
||||
if (import.meta.env.MODE === 'cdn-fonts') {
|
||||
const createLink = (href: string) => {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = href
|
||||
link.media = 'print'
|
||||
link.onload = () => {
|
||||
link.media = 'all'
|
||||
}
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
createLink('https://unpkg.com/subsetted-fonts@latest/MiSans-VF/MiSans-VF.css')
|
||||
createLink('https://unpkg.com/subsetted-fonts@latest/SarasaUiSC-Regular/SarasaUiSC-Regular.css')
|
||||
createLink('https://unpkg.com/subsetted-fonts@latest/PingFangSC-Regular/PingFangSC-Regular.css')
|
||||
createLink('https://unpkg.com/@fontsource/fira-sans')
|
||||
} else if (import.meta.env.MODE === 'MiSans') {
|
||||
import('subsetted-fonts/MiSans-VF/MiSans-VF.css')
|
||||
} else if (import.meta.env.MODE === 'SarasaUi') {
|
||||
import('subsetted-fonts/SarasaUiSC-Regular/SarasaUiSC-Regular.css')
|
||||
} else if (import.meta.env.MODE === 'PingFang') {
|
||||
import('subsetted-fonts/PingFangSC-Regular/PingFangSC-Regular.css')
|
||||
} else if (import.meta.env.MODE === 'FiraSans') {
|
||||
import('@fontsource/fira-sans/index.css')
|
||||
} else if (import.meta.env.MODE === 'SystemUI') {
|
||||
} else {
|
||||
import('@fontsource/fira-sans/index.css')
|
||||
import('subsetted-fonts/MiSans-VF/MiSans-VF.css')
|
||||
import('subsetted-fonts/SarasaUiSC-Regular/SarasaUiSC-Regular.css')
|
||||
import('subsetted-fonts/PingFangSC-Regular/PingFangSC-Regular.css')
|
||||
}
|
||||
}
|
||||
376
src/assets/main.css
Normal file
@@ -0,0 +1,376 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@config '../../tailwind.config.ts';
|
||||
@plugin 'daisyui' {
|
||||
themes: all;
|
||||
exclude: rootscrollgutter;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-MiSans-NotoEmoji: 'MiSans-VF', 'NotoEmoji', system-ui;
|
||||
--font-SarasaUI-NotoEmoji: 'SarasaUiSC-Regular', 'NotoEmoji', system-ui;
|
||||
--font-PingFang-NotoEmoji: 'PingFangSC-Regular', 'NotoEmoji', system-ui;
|
||||
--font-FiraSans-NotoEmoji: 'Fira Sans', 'NotoEmoji', system-ui;
|
||||
--font-SystemUI-NotoEmoji: 'NotoEmoji', system-ui;
|
||||
|
||||
--font-MiSans-Twemoji: 'MiSans-VF', 'Twemoji', system-ui;
|
||||
--font-SarasaUI-Twemoji: 'SarasaUiSC-Regular', 'Twemoji', system-ui;
|
||||
--font-PingFang-Twemoji: 'PingFangSC-Regular', 'Twemoji', system-ui;
|
||||
--font-FiraSans-Twemoji: 'Fira Sans', 'Twemoji', system-ui;
|
||||
--font-SystemUI-Twemoji: 'Twemoji', system-ui;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@font-face {
|
||||
font-family: 'NotoEmoji';
|
||||
src: url('./NotoColorEmoji-flagsonly.ttf') format('truetype');
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@font-face {
|
||||
font-family: 'Twemoji';
|
||||
src: url('./TwemojiMozilla-flags.woff2') format('woff2');
|
||||
}
|
||||
}
|
||||
@utility card {
|
||||
@apply bg-base-100 rounded-lg shadow-md;
|
||||
.card-body {
|
||||
@apply p-4;
|
||||
}
|
||||
}
|
||||
|
||||
@utility collapse {
|
||||
@apply bg-base-100 rounded-lg shadow-md;
|
||||
}
|
||||
|
||||
.collapse.transparent-collapse {
|
||||
@apply bg-transparent! shadow-none! backdrop-blur-none!;
|
||||
}
|
||||
|
||||
@utility badge {
|
||||
@apply bg-base-200/80;
|
||||
}
|
||||
|
||||
@utility select {
|
||||
@apply w-auto;
|
||||
}
|
||||
|
||||
@utility btn {
|
||||
@apply border-none;
|
||||
}
|
||||
|
||||
@utility tabs {
|
||||
.tab {
|
||||
@apply px-2;
|
||||
}
|
||||
.tab.tab-active {
|
||||
@apply bg-primary text-primary-content;
|
||||
}
|
||||
}
|
||||
|
||||
@utility tab {
|
||||
@apply max-md:min-w-24;
|
||||
}
|
||||
|
||||
@utility table {
|
||||
& tr,
|
||||
& th,
|
||||
& td {
|
||||
@apply border-0;
|
||||
}
|
||||
@apply border-separate border-spacing-0;
|
||||
}
|
||||
|
||||
@utility modal {
|
||||
@apply max-md:bg-transparent! max-md:backdrop-blur-sm max-md:transition-[backdrop-filter];
|
||||
}
|
||||
|
||||
.select.select-sm,
|
||||
.input.input-sm,
|
||||
.btn.btn-sm,
|
||||
.tabs.tabs-xs .tab {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
@for $i from 0 to 100 {
|
||||
.custom-background.custom-background-$(i) {
|
||||
.bg-base-100,
|
||||
.bg-primary,
|
||||
.card,
|
||||
.collapse,
|
||||
.input,
|
||||
.select,
|
||||
.toggle,
|
||||
.settings-menu,
|
||||
.ctrls-bar,
|
||||
.tabs {
|
||||
@apply bg-base-100/$(i);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply bg-base-200/$(i) border-base-200/$(i);
|
||||
}
|
||||
|
||||
option,
|
||||
optgroup {
|
||||
@apply backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.bg-base-200,
|
||||
.dock,
|
||||
.table tbody tr:nth-child(even) {
|
||||
@apply bg-base-200/$(i);
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
@if $i > 70 {
|
||||
@apply bg-base-100/$(i);
|
||||
} @else {
|
||||
@apply bg-base-100/70;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-primary:not(.tab.tab-active) {
|
||||
@if $i > 60 {
|
||||
@apply bg-primary/$(i);
|
||||
} @else {
|
||||
@apply bg-primary/60;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-primary .latency-tag {
|
||||
@if $i > 90 {
|
||||
@apply bg-base-100/$(i);
|
||||
} @else {
|
||||
@apply bg-base-100/90;
|
||||
}
|
||||
}
|
||||
|
||||
.home-page.bg-base-200\/50 {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 0 to 40 {
|
||||
.blur-intensity-$(i) {
|
||||
.card,
|
||||
.collapse,
|
||||
.sidebar,
|
||||
.nav-bar,
|
||||
.modal-box,
|
||||
.need-blur,
|
||||
.table thead {
|
||||
/* prettier-ignore */
|
||||
backdrop-filter: blur($(i)px);
|
||||
}
|
||||
|
||||
.pinned-td {
|
||||
@apply bg-transparent! backdrop-blur-xl;
|
||||
}
|
||||
|
||||
.table tbody::before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* prettier-ignore */
|
||||
backdrop-filter: blur($(i)px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tippy-box {
|
||||
@apply bg-neutral text-neutral-content z-[9999] whitespace-pre-line shadow-md;
|
||||
}
|
||||
.tippy-box[data-placement^='top'] > .tippy-arrow:before {
|
||||
@apply border-t-neutral;
|
||||
}
|
||||
.tippy-box[data-placement^='right'] > .tippy-arrow:before {
|
||||
@apply border-r-neutral;
|
||||
}
|
||||
.tippy-box[data-placement^='bottom'] > .tippy-arrow:before {
|
||||
@apply border-b-neutral;
|
||||
}
|
||||
.tippy-box[data-placement^='left'] > .tippy-arrow:before {
|
||||
@apply border-l-neutral;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme^='base'] {
|
||||
@apply bg-base-100 border-base-200 text-base-content z-9999 border shadow-md;
|
||||
}
|
||||
.tippy-box[data-theme^='base'][data-placement^='top'] > .tippy-arrow:before {
|
||||
@apply border-t-base-100;
|
||||
}
|
||||
.tippy-box[data-theme^='base'][data-placement^='right'] > .tippy-arrow:before {
|
||||
@apply border-r-base-100;
|
||||
}
|
||||
.tippy-box[data-theme^='base'][data-placement^='bottom'] > .tippy-arrow:before {
|
||||
@apply border-b-base-100;
|
||||
}
|
||||
.tippy-box[data-theme^='base'][data-placement^='left'] > .tippy-arrow:before {
|
||||
@apply border-l-base-100;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme^='transparent'] {
|
||||
@apply rounded-lg;
|
||||
.tippy-content {
|
||||
@apply p-0;
|
||||
}
|
||||
}
|
||||
.tippy-box[data-theme^='transparent'][data-placement^='top'] {
|
||||
@apply -mb-2;
|
||||
}
|
||||
.tippy-box[data-theme^='transparent'][data-placement^='right'] {
|
||||
@apply -ml-2;
|
||||
}
|
||||
.tippy-box[data-theme^='transparent'][data-placement^='bottom'] {
|
||||
@apply -mt-2;
|
||||
}
|
||||
.tippy-box[data-theme^='transparent'][data-placement^='left'] {
|
||||
@apply -mr-2;
|
||||
}
|
||||
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active,
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active {
|
||||
transition: all 0.2s ease-in-out;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.slide-right-enter-from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.slide-left-leave-from,
|
||||
.slide-right-leave-from,
|
||||
.slide-left-leave-to,
|
||||
.slide-right-leave-to {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slide-left-enter-to,
|
||||
.slide-right-enter-to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
div {
|
||||
@apply scrollbar-thin;
|
||||
}
|
||||
|
||||
.text-main {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
[data-theme='dark'] .text-main {
|
||||
color: oklch(72% 0.233 277.117);
|
||||
}
|
||||
|
||||
[data-theme='lofi'] .text-main,
|
||||
[data-theme='pastel'] .text-main,
|
||||
[data-theme='wireframe'] .text-main,
|
||||
[data-theme='business'] .text-main,
|
||||
[data-theme='black'] .text-main {
|
||||
@apply text-info;
|
||||
}
|
||||
|
||||
@keyframes bounceIn {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounceInForPC {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.bounce-in {
|
||||
animation: bounceIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
.bounce-in {
|
||||
animation: bounceInForPC 0.4s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progressBar {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
|
||||
.dock-shadow {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgba(0, 0, 0, 0.3),
|
||||
rgba(0, 0, 0, 0.16),
|
||||
rgba(0, 0, 0, 0.08),
|
||||
rgba(0, 0, 0, 0.02),
|
||||
rgba(0, 0, 0, 0)
|
||||
);
|
||||
width: 100%;
|
||||
height: env(safe-area-inset-bottom);
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.ctrls-bar {
|
||||
@media screen and (max-width: 768px) {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
var(--color-base-100),
|
||||
color-mix(in srgb, var(--color-base-100) 10%, transparent)
|
||||
);
|
||||
}
|
||||
@apply md:bg-base-100/20 fixed top-0 right-0 left-0 z-30 shadow-sm backdrop-blur-lg md:sticky;
|
||||
}
|
||||
|
||||
.dock {
|
||||
@apply absolute right-2 left-2 z-30 rounded-3xl;
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
@apply flex items-center gap-2 py-2 text-lg font-bold;
|
||||
}
|
||||
.settings-grid {
|
||||
@apply grid max-w-7xl grid-cols-1 gap-x-4 gap-y-1 lg:grid-cols-2 lg:gap-x-8 xl:gap-x-12 2xl:gap-x-16;
|
||||
}
|
||||
.sidebar-collapsed .settings-grid {
|
||||
@apply md:grid-cols-2;
|
||||
}
|
||||
.setting-item {
|
||||
@apply border-base-300/80 flex h-10 items-center gap-2 border-b;
|
||||
.setting-item-label {
|
||||
@apply flex-1 text-sm font-medium;
|
||||
svg {
|
||||
@apply inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
src/assets/metacubex.jpg
Normal file
|
After Width: | Height: | Size: 14 KiB |
37
src/assets/sing-box.svg
Normal file
@@ -0,0 +1,37 @@
|
||||
<svg width="1027" height="1109" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" overflow="hidden">
|
||||
<defs>
|
||||
<filter id="fx0" x="-10%" y="-10%" width="120%" height="120%" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse">
|
||||
<feComponentTransfer color-interpolation-filters="sRGB">
|
||||
<feFuncR type="discrete" tableValues="0 0" />
|
||||
<feFuncG type="discrete" tableValues="0 0" />
|
||||
<feFuncB type="discrete" tableValues="0 0" />
|
||||
<feFuncA type="linear" slope="0.4" intercept="0" />
|
||||
</feComponentTransfer>
|
||||
<feGaussianBlur stdDeviation="4.58333 4.58333" />
|
||||
</filter>
|
||||
<clipPath id="clip1">
|
||||
<rect x="692" y="855" width="1027" height="1109" />
|
||||
</clipPath>
|
||||
<clipPath id="clip2">
|
||||
<rect x="-2" y="-2" width="541" height="786" />
|
||||
</clipPath>
|
||||
<clipPath id="clip3">
|
||||
<rect x="0" y="0" width="535" height="782" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#clip1)" transform="translate(-692 -855)">
|
||||
<path d="M692 1191 692 1575.69C692 1640.41 731.499 1651.19 731.499 1651.19L1148.03 1931.62C1212.66 1974.77 1194.71 1881.29 1194.71 1881.29L1194.71 1528.96 692 1191Z" fill="#37474F" fill-rule="evenodd" />
|
||||
<g clip-path="url(#clip2)" filter="url(#fx0)" transform="translate(1184 1182)">
|
||||
<g clip-path="url(#clip3)">
|
||||
<path d="M520.482 15.4819 520.482 400.176C520.482 464.89 480.983 475.676 480.983 475.676 480.983 475.676 129.086 712.963 64.4523 756.106-0.181814 799.25 17.7721 705.773 17.7721 705.773L17.7721 353.437 520.482 15.4819Z" fill="#455A64" fill-rule="evenodd" />
|
||||
</g>
|
||||
</g>
|
||||
<path d="M1698 1191 1698 1575.69C1698 1640.41 1658.5 1651.19 1658.5 1651.19 1658.5 1651.19 1306.6 1888.48 1241.97 1931.62 1177.34 1974.77 1195.29 1881.29 1195.29 1881.29L1195.29 1528.96 1698 1191Z" fill="#455A64" fill-rule="evenodd" />
|
||||
<path d="M1241.71 868.473C1212.96 850.509 1169.85 850.509 1144.7 868.473L713.557 1163.07C684.814 1181.04 684.814 1213.37 713.557 1231.33L1144.7 1529.53C1173.44 1547.49 1216.56 1547.49 1241.71 1529.53L1676.44 1227.74C1705.19 1209.78 1705.19 1177.44 1676.44 1159.48L1241.71 868.473Z" fill="#546E7A" fill-rule="evenodd" />
|
||||
<path d="M1195 1949C1173.4 1949 1159 1935.19 1159 1917.92L1159 1531.08C1159 1513.82 1173.4 1500 1195 1500 1216.6 1500 1231 1513.82 1231 1531.08L1231 1914.46C1231 1935.19 1216.6 1949 1195 1949Z" fill="#546E7A" fill-rule="evenodd" />
|
||||
<path d="M1553.92 1435.92C1553.92 1471.89 1557.5 1486.27 1518.03 1511.45L1428.32 1568.99C1388.85 1594.17 1374.5 1572.59 1374.5 1540.22L1374.5 1446.71C1374.5 1439.52 1374.5 1435.92 1363.73 1428.73 1270.43 1363.99 911.591 1115.84 847 1069.09L1012.07 954C1058.72 982.772 1399.61 1209.35 1539.56 1306.45 1546.74 1310.05 1550.33 1317.24 1550.33 1320.84L1550.33 1435.92Z" fill="#99AAB5" fill-rule="evenodd" />
|
||||
<path d="M1543.41 1310.21C1399.82 1213.17 1058.79 986.752 1015.72 958L951.103 997.534 847 1069.41C911.615 1116.14 1270.59 1360.53 1363.92 1425.22 1371.1 1428.81 1371.1 1432.41 1371.1 1436L1547 1313.8C1547 1313.8 1547 1310.21 1543.41 1310.21Z" fill="#CCD6DD" fill-rule="evenodd" />
|
||||
<path d="M1554.9 1435.48 1554.9 1324.19C1554.9 1317.01 1551.3 1313.42 1544.11 1309.83 1400.28 1212.89 1058.67 986.721 1015.51 958L940 1008.26C1062.26 1090.83 1389.49 1306.24 1475.79 1367.27 1486.58 1374.45 1486.58 1381.63 1486.58 1385.22L1486.58 1536 1522.54 1510.87C1558.5 1485.74 1554.9 1467.79 1554.9 1435.48Z" fill="#CCD6DD" fill-rule="evenodd" />
|
||||
<path d="M1543.23 1309.95C1399.6 1212.98 1058.49 986.731 1015.4 958L940 1008.28C1062.08 1090.88 1388.83 1306.36 1475.01 1367.41 1475.01 1367.41 1478.6 1371 1478.6 1371L1554 1317.13C1546.82 1313.54 1546.82 1309.95 1543.23 1309.95Z" fill="#E1E8ED" fill-rule="evenodd" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
48
src/assets/theme.css
Normal file
@@ -0,0 +1,48 @@
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'dark-legacy';
|
||||
--color-primary: oklch(65.69% 0.196 275.75);
|
||||
--color-primary-content: #050617;
|
||||
--color-secondary: oklch(74.8% 0.26 342.55);
|
||||
--color-secondary-content: #190211;
|
||||
--color-accent: oklch(74.51% 0.167 183.61);
|
||||
--color-accent-content: #000e0c;
|
||||
--color-neutral: #2a323c;
|
||||
--color-neutral-content: #a6adbb;
|
||||
--color-base-100: #1d232a;
|
||||
--color-base-200: #191e24;
|
||||
--color-base-300: #15191e;
|
||||
--color-base-content: #a6adbb;
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'light-legacy';
|
||||
--color-primary: oklch(49.12% 0.3096 275.75);
|
||||
--color-primary-content: #d4dbff;
|
||||
--color-secondary: oklch(69.71% 0.329 342.55);
|
||||
--color-secondary-content: oklch(98.71% 0.0106 342.55);
|
||||
--color-accent: oklch(76.76% 0.184 183.61);
|
||||
--color-accent-content: #00100d;
|
||||
--color-neutral: #2b3440;
|
||||
--color-neutral-content: #d7dde4;
|
||||
--color-base-100: oklch(100% 0 0);
|
||||
--color-base-200: #f2f2f2;
|
||||
--color-base-300: #e5e6e6;
|
||||
--color-base-content: #1f2937;
|
||||
--radius-selector: 1rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
21
src/components/common/BackendVersion.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1 overflow-hidden">
|
||||
<img
|
||||
:src="isSingBox ? SingBoxLogo : MetacubexLogo"
|
||||
class="h-4 w-4 rounded-xs"
|
||||
/>
|
||||
<span
|
||||
class="truncate"
|
||||
@mouseenter="checkTruncation"
|
||||
>
|
||||
{{ version }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isSingBox, version } from '@/api'
|
||||
import MetacubexLogo from '@/assets/metacubex.jpg'
|
||||
import SingBoxLogo from '@/assets/sing-box.svg'
|
||||
import { checkTruncation } from '@/helper/tooltip'
|
||||
</script>
|
||||
59
src/components/common/CollapseCard.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div :class="`collapse ${showCollapse ? 'collapse-open' : 'collapse-close'}`">
|
||||
<div
|
||||
class="collapse-title cursor-pointer overflow-hidden pr-4"
|
||||
@click="showCollapse = !showCollapse"
|
||||
>
|
||||
<slot name="title" />
|
||||
<slot
|
||||
v-if="!showCollapse"
|
||||
name="preview"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="collapse-content max-sm:px-2"
|
||||
@transitionend="handlerTransitionEnd"
|
||||
>
|
||||
<div
|
||||
v-if="showContent"
|
||||
class="max-h-108 overflow-y-auto"
|
||||
:class="[SCROLLABLE_PARENT_CLASS, !showCollapse && 'opacity-0']"
|
||||
>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SCROLLABLE_PARENT_CLASS } from '@/helper/utils'
|
||||
import { collapseGroupMap } from '@/store/settings'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const showCollapse = computed({
|
||||
get() {
|
||||
return collapseGroupMap.value[props.name]
|
||||
},
|
||||
set(value) {
|
||||
collapseGroupMap.value[props.name] = value
|
||||
},
|
||||
})
|
||||
|
||||
watch(showCollapse, (value) => {
|
||||
if (value) {
|
||||
showContent.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const showContent = ref(showCollapse.value)
|
||||
|
||||
const handlerTransitionEnd = () => {
|
||||
if (!showCollapse.value) {
|
||||
showContent.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
59
src/components/common/DialogWrapper.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<dialog
|
||||
ref="modalRef"
|
||||
class="modal"
|
||||
@close="isOpen = false"
|
||||
>
|
||||
<form
|
||||
method="dialog"
|
||||
class="modal-backdrop w-screen"
|
||||
>
|
||||
<button class="!outline-none">close</button>
|
||||
</form>
|
||||
<div
|
||||
class="modal-box relative overflow-hidden p-0"
|
||||
:class="[blurIntensity < 5 && 'backdrop-blur-sm!', boxClass]"
|
||||
>
|
||||
<div
|
||||
v-if="title && isOpen"
|
||||
class="border-base-content/10 relative border-b px-4 py-2 text-base font-bold"
|
||||
>
|
||||
{{ title }}
|
||||
<slot name="title-right"></slot>
|
||||
<form
|
||||
method="dialog"
|
||||
class="-mr-1"
|
||||
>
|
||||
<button class="btn btn-circle btn-ghost btn-xs absolute top-2 right-2">
|
||||
<XMarkIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="max-h-[90dvh] overflow-y-auto max-md:max-h-[70dvh]"
|
||||
:class="noPadding ? 'p-0' : 'p-4'"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { blurIntensity } from '@/store/settings'
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const modalRef = ref<HTMLDialogElement>()
|
||||
const isOpen = defineModel<boolean>()
|
||||
defineProps<{ noPadding?: boolean; boxClass?: string; title?: string }>()
|
||||
|
||||
watch(isOpen, (value) => {
|
||||
if (value) {
|
||||
modalRef.value?.showModal()
|
||||
} else {
|
||||
modalRef.value?.close()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
131
src/components/common/ImportSettings.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="importDialogShow = true"
|
||||
>
|
||||
{{ $t('importSettings') }}
|
||||
</button>
|
||||
<DialogWrapper
|
||||
v-model="importDialogShow"
|
||||
:title="$t('importSettings')"
|
||||
>
|
||||
<div class="my-4 flex items-center gap-2">
|
||||
{{ $t('importFromFile') }}
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="importSettingsFromFile"
|
||||
>
|
||||
{{ $t('importFromFile') }}
|
||||
<ArrowUpCircleIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="my-4 flex items-center gap-2 max-sm:flex-col max-sm:items-start">
|
||||
{{ $t('importFromUrl') }}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="join">
|
||||
<TextInput
|
||||
v-model="importSettingsUrl"
|
||||
class="max-w-60"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-sm join-item"
|
||||
@click="importSettingsFromUrlHandler()"
|
||||
>
|
||||
<ArrowDownTrayIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<QuestionMarkCircleIcon
|
||||
v-if="importSettingsUrl === DEFAULT_SETTINGS_URL"
|
||||
class="h-4 w-4"
|
||||
@mouseenter="
|
||||
showTip($event, $t('importFromBackendTip'), {
|
||||
appendTo: 'parent',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-sm"
|
||||
@click="importSettingsUrl = DEFAULT_SETTINGS_URL"
|
||||
>
|
||||
{{ $t('reset') }} URL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-4 flex items-center gap-2">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<span>{{ $t('autoImportFromUrl') }}</span>
|
||||
<input
|
||||
v-model="autoImportSettings"
|
||||
type="checkbox"
|
||||
class="toggle toggle-sm"
|
||||
/>
|
||||
</label>
|
||||
<QuestionMarkCircleIcon
|
||||
class="h-4 w-4"
|
||||
@mouseenter="
|
||||
showTip($event, $t('autoImportFromUrlTip'), {
|
||||
appendTo: 'parent',
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
@change="handlerJsonUpload"
|
||||
/>
|
||||
</DialogWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
autoImportSettings,
|
||||
DEFAULT_SETTINGS_URL,
|
||||
importSettingsFromUrl,
|
||||
importSettingsUrl,
|
||||
} from '@/helper/autoImportSettings'
|
||||
import { showNotification } from '@/helper/notification'
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
ArrowUpCircleIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { ref } from 'vue'
|
||||
import DialogWrapper from './DialogWrapper.vue'
|
||||
import TextInput from './TextInput.vue'
|
||||
|
||||
const inputRef = ref<HTMLInputElement>()
|
||||
const importDialogShow = ref(false)
|
||||
|
||||
const { showTip } = useTooltip()
|
||||
|
||||
const handlerJsonUpload = () => {
|
||||
showNotification({
|
||||
content: 'importing',
|
||||
})
|
||||
const file = inputRef.value?.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const settings = JSON.parse(reader.result as string)
|
||||
|
||||
for (const key in settings) {
|
||||
localStorage.setItem(key, settings[key])
|
||||
}
|
||||
location.reload()
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const importSettingsFromFile = () => {
|
||||
inputRef.value?.click()
|
||||
}
|
||||
const importSettingsFromUrlHandler = async () => {
|
||||
importDialogShow.value = false
|
||||
await importSettingsFromUrl(true)
|
||||
}
|
||||
</script>
|
||||
29
src/components/common/ProxyChains.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
v-for="(name, index) in proxyChains"
|
||||
:key="name"
|
||||
>
|
||||
<div
|
||||
v-if="index > 0"
|
||||
class="border-base-content/15 border-b"
|
||||
/>
|
||||
<ProxyGroup
|
||||
:name="name"
|
||||
class="transparent-collapse"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getProxyGroupChains } from '@/store/proxies'
|
||||
import { computed } from 'vue'
|
||||
import ProxyGroup from '../proxies/ProxyGroup.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const proxyChains = computed(() => {
|
||||
return getProxyGroupChains(props.name)
|
||||
})
|
||||
</script>
|
||||
130
src/components/common/TextInput.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<XMarkIcon
|
||||
v-if="beforeClose && clearable"
|
||||
class="absolute top-2 right-2 z-10 h-4 w-3 cursor-pointer hover:scale-125"
|
||||
@click="clearInput"
|
||||
/>
|
||||
<input
|
||||
v-model="inputValue"
|
||||
type="text"
|
||||
:class="['input input-sm join-item w-full', { 'pr-6': clearable }]"
|
||||
:placeholder="placeholder || ''"
|
||||
:name="name || ''"
|
||||
:autocomplete="autocomplete || ''"
|
||||
@click="handlerSearchInputClick"
|
||||
@input="(emits('input', inputValue || ''), hideTip())"
|
||||
@change="emits('change', inputValue || '')"
|
||||
/>
|
||||
<XMarkIcon
|
||||
v-if="!beforeClose && clearable"
|
||||
class="absolute top-2 right-2 z-10 h-4 w-3 cursor-pointer hover:scale-125"
|
||||
@click="clearInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
import { createApp, defineComponent, h } from 'vue'
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'input', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
(e: 'update:menus', value: string[]): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
placeholder?: string
|
||||
beforeClose?: boolean
|
||||
name?: string
|
||||
autocomplete?: string
|
||||
clearable?: boolean
|
||||
menus?: string[]
|
||||
menusDeleteable?: boolean
|
||||
}>()
|
||||
|
||||
const inputValue = defineModel<string>()
|
||||
const clearInput = () => {
|
||||
inputValue.value = ''
|
||||
}
|
||||
|
||||
const { showTip, hideTip } = useTooltip()
|
||||
|
||||
const handlerSearchInputClick = (e: Event) => {
|
||||
if (!props.menus?.length) {
|
||||
return
|
||||
}
|
||||
const PopContent = defineComponent({
|
||||
props: {
|
||||
menus: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
menusDeleteable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props: { menus: string[]; menusDeleteable: boolean }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ class: 'max-h-64 overflow-y-auto overflow-x-hidden scrollbar-hidden min-w-24 py-1' },
|
||||
props.menus.map((item) =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class:
|
||||
'cursor-pointer rounded-sm p-1 px-3 flex gap-2 items-center overflow-hidden hover:bg-base-300',
|
||||
},
|
||||
[
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: 'flex-1 truncate',
|
||||
onClick: () => {
|
||||
inputValue.value = item
|
||||
hideTip()
|
||||
},
|
||||
},
|
||||
item,
|
||||
),
|
||||
props.menusDeleteable &&
|
||||
h(XMarkIcon, {
|
||||
class: 'h-3 w-3 transition-transform hover:scale-125',
|
||||
onClick: (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
emits(
|
||||
'update:menus',
|
||||
props.menus.filter((menu) => menu !== item),
|
||||
)
|
||||
target.closest('div')?.remove()
|
||||
},
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
const mountEl = document.createElement('div')
|
||||
const app = createApp(PopContent, {
|
||||
menus: props.menus,
|
||||
menusDeleteable: props.menusDeleteable,
|
||||
})
|
||||
|
||||
app.mount(mountEl)
|
||||
|
||||
showTip(e, mountEl, {
|
||||
theme: 'base',
|
||||
placement: 'bottom-start',
|
||||
trigger: 'click',
|
||||
interactive: true,
|
||||
appendTo: document.body,
|
||||
arrow: false,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
94
src/components/common/VirtualScroller.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div
|
||||
ref="parentRef"
|
||||
class="flex h-full w-full flex-col overflow-y-auto"
|
||||
>
|
||||
<slot name="before" />
|
||||
<div
|
||||
:style="{
|
||||
height: `${totalSize}px`,
|
||||
}"
|
||||
class="relative w-full"
|
||||
v-if="data.length > 0"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full p-2"
|
||||
:style="{
|
||||
transform: `translateY(${virtualRows[0]?.start ?? 0}px)`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="row in virtualRows"
|
||||
:key="row.key.toString()"
|
||||
:data-index="row.index"
|
||||
:ref="(ref) => measureElement(ref as Element | null)"
|
||||
:style="{ marginBottom: marginBottom(row.index) }"
|
||||
>
|
||||
<slot
|
||||
:item="data[row.index]"
|
||||
:index="row.index"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="card m-2 flex-row p-2 text-sm"
|
||||
>
|
||||
{{ $t('noContent') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePaddingForViews } from '@/composables/paddingViews'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
const { paddingTop, paddingBottom } = usePaddingForViews({
|
||||
offsetTop: 0,
|
||||
offsetBottom: 0,
|
||||
})
|
||||
const parentRef = ref<HTMLElement | null>(null)
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any[]
|
||||
size?: number
|
||||
overscan?: number
|
||||
}>(),
|
||||
{
|
||||
data: () => [],
|
||||
size: 64,
|
||||
overscan: 24,
|
||||
},
|
||||
)
|
||||
const virutalOptions = computed(() => {
|
||||
return {
|
||||
count: props.data.length,
|
||||
getScrollElement: () => parentRef.value,
|
||||
estimateSize: () => props.size,
|
||||
overscan: props.overscan,
|
||||
paddingStart: paddingTop.value,
|
||||
}
|
||||
})
|
||||
|
||||
const rowVirtualizer = useVirtualizer(virutalOptions)
|
||||
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems())
|
||||
const totalSize = computed(() => rowVirtualizer.value.getTotalSize())
|
||||
|
||||
const marginBottom = (index: number) => {
|
||||
return index === props.data.length - 1 ? `${paddingBottom.value}px` : '4px'
|
||||
}
|
||||
const measureElement = (el: Element | null) => {
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
rowVirtualizer.value.measureElement(el)
|
||||
})
|
||||
|
||||
return undefined
|
||||
}
|
||||
</script>
|
||||
195
src/components/connections/ConnectionCard.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { blockConnectionByIdAPI, disconnectByIdAPI } from '@/api'
|
||||
import { useBounceOnVisible } from '@/composables/bouncein'
|
||||
import { useConnections } from '@/composables/connections'
|
||||
import {
|
||||
CONNECTION_TAB_TYPE,
|
||||
CONNECTIONS_TABLE_ACCESSOR_KEY,
|
||||
PROXY_CHAIN_DIRECTION,
|
||||
} from '@/constant'
|
||||
import {
|
||||
getDestinationFromConnection,
|
||||
getDestinationTypeFromConnection,
|
||||
getHostFromConnection,
|
||||
getInboundUserFromConnection,
|
||||
getNetworkTypeFromConnection,
|
||||
getProcessFromConnection,
|
||||
} from '@/helper'
|
||||
import { getIPLabelFromMap } from '@/helper/sourceip'
|
||||
import { fromNow, prettyBytesHelper } from '@/helper/utils'
|
||||
import { connectionTabShow } from '@/store/connections'
|
||||
import { connectionCardLines, proxyChainDirection } from '@/store/settings'
|
||||
import type { Connection } from '@/types'
|
||||
import {
|
||||
ArrowDownCircleIcon,
|
||||
ArrowDownIcon,
|
||||
ArrowRightCircleIcon,
|
||||
ArrowUpCircleIcon,
|
||||
ArrowUpIcon,
|
||||
NoSymbolIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { first, last } from 'lodash'
|
||||
import { defineComponent } from 'vue'
|
||||
import type { JSX } from 'vue/jsx-runtime'
|
||||
import ProxyName from '../proxies/ProxyName.vue'
|
||||
|
||||
export default defineComponent<{
|
||||
conn: Connection
|
||||
}>({
|
||||
props: {
|
||||
conn: Object,
|
||||
},
|
||||
name: 'ConnectionCard',
|
||||
setup(props) {
|
||||
const { handlerInfo } = useConnections()
|
||||
|
||||
useBounceOnVisible()
|
||||
|
||||
return () => {
|
||||
const conn = props.conn
|
||||
const metadata = conn.metadata
|
||||
const componentMap: Record<CONNECTIONS_TABLE_ACCESSOR_KEY, JSX.Element> = {
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Host]: (
|
||||
<span class="text-main w-80 grow truncate">{getHostFromConnection(conn)}</span>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Destination]: (
|
||||
<span class="w-80 grow truncate break-all">{getDestinationFromConnection(conn)}</span>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.RemoteAddress]: (
|
||||
<span class="w-80 grow truncate break-all">{conn.metadata.remoteDestination || '-'}</span>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.SourceIP]: (
|
||||
<span class="w-40 grow truncate break-all">{getIPLabelFromMap(metadata.sourceIP)}</span>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.SourcePort]: (
|
||||
<span class="w-20 grow truncate break-all">{metadata.sourcePort}</span>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.SniffHost]: (
|
||||
<span class="w-80 grow truncate break-all">{metadata.sniffHost || '-'}</span>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Type]: (
|
||||
<span class="w-60 grow truncate break-all">{getNetworkTypeFromConnection(conn)}</span>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Rule]: (
|
||||
<span class="w-80 grow truncate break-all">
|
||||
{conn.rule}
|
||||
{conn.rulePayload && <>: {conn.rulePayload}</>}
|
||||
</span>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Process]: (
|
||||
<span class="w-60 grow truncate break-all">{getProcessFromConnection(conn)}</span>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Chains]: (
|
||||
<span
|
||||
class={[
|
||||
'flex w-80 grow items-center gap-1 truncate break-all',
|
||||
proxyChainDirection.value === PROXY_CHAIN_DIRECTION.REVERSE &&
|
||||
'flex-row-reverse justify-end',
|
||||
]}
|
||||
>
|
||||
{<ProxyName name={last(conn.chains)!} />}
|
||||
{last(conn.chains) !== first(conn.chains) && (
|
||||
<>
|
||||
<ArrowRightCircleIcon class="h-4 w-4 shrink-0"></ArrowRightCircleIcon>
|
||||
{<ProxyName name={first(conn.chains)!} />}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Outbound]: (
|
||||
<span class="w-60 grow truncate break-all">{conn.chains[0]}</span>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Download]: (
|
||||
<div class="flex items-center gap-1 text-xs whitespace-nowrap">
|
||||
{prettyBytesHelper(conn.download)}
|
||||
<ArrowDownIcon class="text-success h-3 w-3" />
|
||||
</div>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Upload]: (
|
||||
<div class="flex items-center gap-1 text-xs whitespace-nowrap">
|
||||
{prettyBytesHelper(conn.upload)}
|
||||
<ArrowUpIcon class="text-info h-3 w-3" />
|
||||
</div>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.DlSpeed]: (
|
||||
<div class="flex items-center gap-1 text-xs whitespace-nowrap">
|
||||
{prettyBytesHelper(conn.downloadSpeed)}/s
|
||||
<ArrowDownCircleIcon class="text-success h-4 w-4" />
|
||||
</div>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.UlSpeed]: (
|
||||
<div class="flex items-center gap-1 text-xs whitespace-nowrap">
|
||||
{prettyBytesHelper(conn.uploadSpeed)}/s
|
||||
<ArrowUpCircleIcon class="text-info h-4 w-4" />
|
||||
</div>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.ConnectTime]: (
|
||||
<div class="gap-1 whitespace-nowrap">{fromNow(conn.start)}</div>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.DestinationType]: (
|
||||
<div class="gap-1 whitespace-nowrap">{getDestinationTypeFromConnection(conn)}</div>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.InboundUser]: (
|
||||
<div class="gap-1 whitespace-nowrap">{getInboundUserFromConnection(conn)}</div>
|
||||
),
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Close]: (() => {
|
||||
const closeButton = (
|
||||
<button
|
||||
class="btn btn-circle btn-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
disconnectByIdAPI(conn.id)
|
||||
}}
|
||||
>
|
||||
<XMarkIcon class="h-4 w-4" />
|
||||
</button>
|
||||
)
|
||||
|
||||
if (metadata.smartBlock === 'normal') {
|
||||
const degradeButton = (
|
||||
<button
|
||||
class="btn btn-circle btn-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
blockConnectionByIdAPI(conn.id)
|
||||
}}
|
||||
>
|
||||
<NoSymbolIcon class="h-4 w-4" />
|
||||
</button>
|
||||
)
|
||||
return (
|
||||
<div class="flex gap-1">
|
||||
{degradeButton}
|
||||
{closeButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return closeButton
|
||||
})(),
|
||||
}
|
||||
return (
|
||||
<div
|
||||
class={[
|
||||
'card cursor-pointer gap-1',
|
||||
connectionCardLines.value.length > 2 ? 'p-2' : 'p-1',
|
||||
]}
|
||||
onClick={() => handlerInfo(conn)}
|
||||
>
|
||||
{connectionCardLines.value.map((line) => (
|
||||
<div class="flex h-5 items-center gap-1 text-sm">
|
||||
{line
|
||||
.filter(
|
||||
(key) =>
|
||||
key !== CONNECTIONS_TABLE_ACCESSOR_KEY.Close ||
|
||||
connectionTabShow.value !== CONNECTION_TAB_TYPE.CLOSED,
|
||||
)
|
||||
.map((key) => {
|
||||
return componentMap[key]
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
26
src/components/connections/ConnectionCardList.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<VirtualScroller
|
||||
:data="renderConnections"
|
||||
:size="size"
|
||||
>
|
||||
<template v-slot:before>
|
||||
<ConnectionCtrl />
|
||||
</template>
|
||||
<template v-slot="{ item }: { item: Connection }">
|
||||
<ConnectionCard :conn="item" />
|
||||
</template>
|
||||
</VirtualScroller>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { renderConnections } from '@/store/connections'
|
||||
import { connectionCardLines } from '@/store/settings'
|
||||
import type { Connection } from '@/types'
|
||||
import { computed } from 'vue'
|
||||
import VirtualScroller from '../common/VirtualScroller.vue'
|
||||
import ConnectionCtrl from '../sidebar/ConnectionCtrl.tsx'
|
||||
import ConnectionCard from './ConnectionCard'
|
||||
const size = computed(() => {
|
||||
return connectionCardLines.value.length * 28 + 4
|
||||
})
|
||||
</script>
|
||||
132
src/components/connections/ConnectionDetails.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<DialogWrapper
|
||||
v-model="connectionDetailModalShow"
|
||||
:title="$t('connectionDetails')"
|
||||
:box-class="proxyChainStart ? `max-w-256` : `max-w-128`"
|
||||
>
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<div class="md:w-128">
|
||||
<VueJsonPretty
|
||||
:data="infoConn"
|
||||
class="overflow-y-auto"
|
||||
>
|
||||
<template #renderNodeValue="{ node, defaultValue }">
|
||||
<template v-if="node.path.startsWith('root.chains') && proxyMap[node.content]?.icon">
|
||||
<span
|
||||
>"<ProxyIcon
|
||||
:icon="proxyMap[node.content].icon"
|
||||
class="inline-block"
|
||||
:margin="0"
|
||||
/>
|
||||
{{ node.content }}"
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ defaultValue }}
|
||||
</template>
|
||||
</template>
|
||||
</VueJsonPretty>
|
||||
<div
|
||||
class="min-h-12 shrink-0 pt-2 text-sm"
|
||||
v-if="destinationIP && !isPrivateIP"
|
||||
>
|
||||
<template v-if="details">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<ArrowRightCircleIcon class="h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
{{ details?.ip }}
|
||||
</div>
|
||||
<div>( AS{{ details?.asn }} )</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap">
|
||||
<div
|
||||
class="mr-3 flex items-center gap-1"
|
||||
v-if="details?.country"
|
||||
>
|
||||
<MapPinIcon class="h-4 w-4 shrink-0" />
|
||||
<template v-if="details?.city && details?.city !== details?.country">
|
||||
{{ details?.city }},
|
||||
</template>
|
||||
<template v-else-if="details?.region && details?.region !== details?.country">
|
||||
{{ details?.region }},
|
||||
</template>
|
||||
{{ details?.country }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<ServerIcon class="h-4 w-4 shrink-0" />
|
||||
{{ details?.organization }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="proxyChainStart">
|
||||
<div class="divider md:divider-horizontal m-0"></div>
|
||||
<div class="md:w-128">
|
||||
<ProxyChains :name="proxyChainStart" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getIPInfo, type IPInfo } from '@/api/geoip'
|
||||
import DialogWrapper from '@/components/common/DialogWrapper.vue'
|
||||
import { useConnections } from '@/composables/connections'
|
||||
import { proxyMap } from '@/store/proxies'
|
||||
import { ArrowRightCircleIcon, MapPinIcon, ServerIcon } from '@heroicons/vue/24/outline'
|
||||
import * as ipaddr from 'ipaddr.js'
|
||||
import { last } from 'lodash'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import VueJsonPretty from 'vue-json-pretty'
|
||||
import 'vue-json-pretty/lib/styles.css'
|
||||
import ProxyChains from '../common/ProxyChains.vue'
|
||||
import ProxyIcon from '../proxies/ProxyIcon.vue'
|
||||
|
||||
const { infoConn, connectionDetailModalShow } = useConnections()
|
||||
const details = ref<IPInfo | null>(null)
|
||||
|
||||
const destinationIP = computed(() => infoConn.value?.metadata.destinationIP)
|
||||
const isPrivateIP = computed(() => {
|
||||
if (!destinationIP.value || !ipaddr.isValid(destinationIP.value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const addr = ipaddr.parse(destinationIP.value)
|
||||
const range = addr.range()
|
||||
|
||||
return ['private', 'uniqueLocal', 'loopback', 'linkLocal'].includes(range)
|
||||
})
|
||||
|
||||
const proxyChainStart = computed(() => {
|
||||
if (!infoConn.value?.chains || !infoConn.value.chains.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return last(infoConn.value.chains)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => destinationIP.value,
|
||||
(newIP) => {
|
||||
if (!newIP) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isPrivateIP.value) {
|
||||
details.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (details.value?.ip === newIP) {
|
||||
return
|
||||
}
|
||||
|
||||
details.value = null
|
||||
getIPInfo(infoConn.value?.metadata.destinationIP).then((res) => {
|
||||
details.value = res
|
||||
})
|
||||
},
|
||||
)
|
||||
</script>
|
||||
711
src/components/connections/ConnectionTable.vue
Normal file
@@ -0,0 +1,711 @@
|
||||
<template>
|
||||
<div
|
||||
ref="parentRef"
|
||||
class="h-full overflow-auto p-2"
|
||||
:class="{
|
||||
'select-none': isDragging,
|
||||
}"
|
||||
@touchstart.passive.stop
|
||||
@touchmove.passive.stop
|
||||
@touchend.passive.stop
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp"
|
||||
@mouseleave="handleMouseUp"
|
||||
>
|
||||
<div :style="{ height: `${totalSize}px` }">
|
||||
<table
|
||||
:class="['table rounded-none shadow-md', sizeOfTable, isManualTable && 'table-fixed']"
|
||||
:style="
|
||||
isManualTable && {
|
||||
width: `${tanstackTable.getCenterTotalSize()}px`,
|
||||
}
|
||||
"
|
||||
>
|
||||
<thead class="bg-base-100 sticky -top-2 z-10">
|
||||
<tr
|
||||
v-for="headerGroup in tanstackTable.getHeaderGroups()"
|
||||
:key="headerGroup.id"
|
||||
>
|
||||
<th
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:colSpan="header.colSpan"
|
||||
class="relative"
|
||||
:class="[
|
||||
header.column.getCanSort() ? 'cursor-pointer select-none' : '',
|
||||
header.column.getIsPinned && header.column.getIsPinned() === 'left'
|
||||
? 'pinned-td bg-base-100 sticky -left-2 z-20'
|
||||
: '',
|
||||
]"
|
||||
:style="
|
||||
isManualTable && {
|
||||
width: `${header.getSize()}px`,
|
||||
}
|
||||
"
|
||||
@click="header.column.getToggleSortingHandler()?.($event)"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
>
|
||||
</FlexRender>
|
||||
<ArrowUpCircleIcon
|
||||
class="h-4 w-4"
|
||||
v-if="header.column.getIsSorted() === 'asc'"
|
||||
/>
|
||||
<ArrowDownCircleIcon
|
||||
class="h-4 w-4"
|
||||
v-if="header.column.getIsSorted() === 'desc'"
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
v-if="header.column.getCanGroup()"
|
||||
class="btn btn-xs btn-circle btn-ghost"
|
||||
@click.stop="() => header.column.getToggleGroupingHandler()()"
|
||||
>
|
||||
<MagnifyingGlassMinusIcon
|
||||
v-if="header.column.getIsGrouped()"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<MagnifyingGlassPlusIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="
|
||||
header.column.id === CONNECTIONS_TABLE_ACCESSOR_KEY.Host ||
|
||||
header.column.id === CONNECTIONS_TABLE_ACCESSOR_KEY.SniffHost
|
||||
"
|
||||
class="btn btn-xs btn-circle btn-ghost"
|
||||
@click.stop="() => handlePinColumn(header.column)"
|
||||
>
|
||||
<MapPinIcon
|
||||
v-if="header.column.getIsPinned() !== 'left'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<XMarkIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isManualTable"
|
||||
@dblclick="() => header.column.resetSize()"
|
||||
@click.stop
|
||||
@mousedown.stop="(e) => header.getResizeHandler()(e)"
|
||||
@touchstart.stop="(e) => header.getResizeHandler()(e)"
|
||||
class="resizer bg-neutral absolute top-0 right-0 h-full w-1 cursor-ew-resize"
|
||||
/>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(virtualRow, index) in virtualRows"
|
||||
:key="virtualRow.key.toString()"
|
||||
:style="{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start - index * virtualRow.size}px)`,
|
||||
}"
|
||||
class="hover:bg-primary! hover:text-primary-content"
|
||||
:class="[
|
||||
index % 2 === 0 ? 'bg-base-100' : 'bg-base-200',
|
||||
!isDragging ? 'cursor-pointer' : 'cursor-grabbing',
|
||||
]"
|
||||
@click="handlerClickRow(rows[virtualRow.index])"
|
||||
>
|
||||
<td
|
||||
v-for="cell in rows[virtualRow.index].getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:class="[
|
||||
isManualTable
|
||||
? 'truncate text-sm'
|
||||
: twMerge(
|
||||
'text-sm whitespace-nowrap',
|
||||
[
|
||||
CONNECTIONS_TABLE_ACCESSOR_KEY.Download,
|
||||
CONNECTIONS_TABLE_ACCESSOR_KEY.DlSpeed,
|
||||
CONNECTIONS_TABLE_ACCESSOR_KEY.Upload,
|
||||
CONNECTIONS_TABLE_ACCESSOR_KEY.UlSpeed,
|
||||
].includes(cell.column.id as CONNECTIONS_TABLE_ACCESSOR_KEY) && 'min-w-20',
|
||||
CONNECTIONS_TABLE_ACCESSOR_KEY.Host ===
|
||||
(cell.column.id as CONNECTIONS_TABLE_ACCESSOR_KEY) && 'max-w-xs truncate',
|
||||
[
|
||||
CONNECTIONS_TABLE_ACCESSOR_KEY.Chains,
|
||||
CONNECTIONS_TABLE_ACCESSOR_KEY.Rule,
|
||||
].includes(cell.column.id as CONNECTIONS_TABLE_ACCESSOR_KEY) &&
|
||||
'max-w-xl truncate',
|
||||
),
|
||||
cell.column.getIsPinned && cell.column.getIsPinned() === 'left'
|
||||
? 'pinned-td sticky -left-2 z-20 bg-inherit shadow-md'
|
||||
: '',
|
||||
]"
|
||||
@contextmenu="handleCellRightClick($event, cell)"
|
||||
>
|
||||
<template v-if="cell.column.getIsGrouped()">
|
||||
<template v-if="rows[virtualRow.index].getCanExpand()">
|
||||
<div class="flex items-center overflow-hidden">
|
||||
<component
|
||||
:is="
|
||||
rows[virtualRow.index].getIsExpanded()
|
||||
? MagnifyingGlassMinusIcon
|
||||
: MagnifyingGlassPlusIcon
|
||||
"
|
||||
class="mr-1 inline-block h-4 w-4 shrink-0"
|
||||
/>
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
class="shrink-1 overflow-hidden"
|
||||
/>
|
||||
<span class="ml-1 shrink-0">
|
||||
({{ rows[virtualRow.index].subRows.length }})
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<FlexRender
|
||||
v-else
|
||||
:render="
|
||||
cell.getIsAggregated()
|
||||
? cell.column.columnDef.aggregatedCell
|
||||
: cell.column.columnDef.cell
|
||||
"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { blockConnectionByIdAPI, disconnectByIdAPI } from '@/api'
|
||||
import { useConnections } from '@/composables/connections'
|
||||
import {
|
||||
CONNECTION_TAB_TYPE,
|
||||
CONNECTIONS_TABLE_ACCESSOR_KEY,
|
||||
PROXY_CHAIN_DIRECTION,
|
||||
TABLE_SIZE,
|
||||
TABLE_WIDTH_MODE,
|
||||
} from '@/constant'
|
||||
import {
|
||||
getDestinationFromConnection,
|
||||
getDestinationTypeFromConnection,
|
||||
getHostFromConnection,
|
||||
getInboundUserFromConnection,
|
||||
getNetworkTypeFromConnection,
|
||||
getProcessFromConnection,
|
||||
} from '@/helper'
|
||||
import { showNotification } from '@/helper/notification'
|
||||
import { getIPLabelFromMap } from '@/helper/sourceip'
|
||||
import { fromNow, prettyBytesHelper } from '@/helper/utils'
|
||||
import { connectionTabShow, renderConnections } from '@/store/connections'
|
||||
import {
|
||||
connectionTableColumns,
|
||||
proxyChainDirection,
|
||||
showFullProxyChain,
|
||||
tableSize,
|
||||
tableWidthMode,
|
||||
} from '@/store/settings'
|
||||
import type { Connection } from '@/types'
|
||||
import {
|
||||
ArrowDownCircleIcon,
|
||||
ArrowRightCircleIcon,
|
||||
ArrowUpCircleIcon,
|
||||
MagnifyingGlassMinusIcon,
|
||||
MagnifyingGlassPlusIcon,
|
||||
MapPinIcon,
|
||||
NoSymbolIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getGroupedRowModel,
|
||||
getSortedRowModel,
|
||||
isFunction,
|
||||
useVueTable,
|
||||
type Column,
|
||||
type ColumnDef,
|
||||
type ColumnPinningState,
|
||||
type ExpandedState,
|
||||
type GroupingState,
|
||||
type Row,
|
||||
type SortingState,
|
||||
} from '@tanstack/vue-table'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { computed, h, ref, type VNode } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ProxyName from '../proxies/ProxyName.vue'
|
||||
const { handlerInfo } = useConnections()
|
||||
const columnWidthMap = useStorage('config/table-column-width', {
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Close]: 50,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Host]: 320,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Chains]: 320,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Rule]: 200,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Download]: 80,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.DlSpeed]: 80,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Upload]: 80,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.UlSpeed]: 80,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Outbound]: 80,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Type]: 150,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Process]: 150,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.SourceIP]: 150,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.SourcePort]: 100,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.SniffHost]: 200,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.Destination]: 150,
|
||||
[CONNECTIONS_TABLE_ACCESSOR_KEY.ConnectTime]: 100,
|
||||
} as Record<CONNECTIONS_TABLE_ACCESSOR_KEY, number>)
|
||||
|
||||
const isManualTable = computed(() => tableWidthMode.value === TABLE_WIDTH_MODE.MANUAL)
|
||||
const { t } = useI18n()
|
||||
const columns: ColumnDef<Connection>[] = [
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.Close),
|
||||
enableSorting: false,
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.Close,
|
||||
cell: ({ row }) => {
|
||||
const closeButton = h(
|
||||
'button',
|
||||
{
|
||||
class: 'btn btn-xs btn-circle',
|
||||
onClick: (e) => {
|
||||
const connection = row.original
|
||||
|
||||
e.stopPropagation()
|
||||
disconnectByIdAPI(connection.id)
|
||||
},
|
||||
},
|
||||
[
|
||||
h(XMarkIcon, {
|
||||
class: 'h-4 w-4',
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
if (row.original.metadata.smartBlock === 'normal') {
|
||||
const degradeButton = h(
|
||||
'button',
|
||||
{
|
||||
class: 'btn btn-xs btn-circle',
|
||||
onClick: (e) => {
|
||||
const connection = row.original
|
||||
|
||||
e.stopPropagation()
|
||||
blockConnectionByIdAPI(connection.id)
|
||||
},
|
||||
},
|
||||
[
|
||||
h(NoSymbolIcon, {
|
||||
class: 'h-4 w-4',
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
return h('div', { class: 'flex gap-1' }, [closeButton, degradeButton])
|
||||
}
|
||||
|
||||
return closeButton
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.Type),
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.Type,
|
||||
accessorFn: getNetworkTypeFromConnection,
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.Process),
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.Process,
|
||||
accessorFn: getProcessFromConnection,
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.Host),
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.Host,
|
||||
accessorFn: getHostFromConnection,
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.SniffHost),
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.SniffHost,
|
||||
accessorFn: (original) => original.metadata.sniffHost || '-',
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.Rule),
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.Rule,
|
||||
accessorFn: (original) =>
|
||||
!original.rulePayload ? original.rule : `${original.rule}: ${original.rulePayload}`,
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.Chains),
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.Chains,
|
||||
accessorFn: (original) => {
|
||||
const chains = [...original.chains]
|
||||
|
||||
return proxyChainDirection.value === PROXY_CHAIN_DIRECTION.REVERSE
|
||||
? chains.join(' → ')
|
||||
: chains.reverse().join(' → ')
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const chains: VNode[] = []
|
||||
const isReverse = proxyChainDirection.value === PROXY_CHAIN_DIRECTION.REVERSE
|
||||
let originChains = row.original.chains
|
||||
|
||||
if (!showFullProxyChain.value && originChains.length > 2) {
|
||||
originChains = [originChains[0], originChains[originChains.length - 1]]
|
||||
}
|
||||
|
||||
// 完整显示所有代理链
|
||||
originChains.forEach((chain, index) => {
|
||||
chains.unshift(h(ProxyName, { name: chain, key: chain }))
|
||||
|
||||
if (index < originChains.length - 1) {
|
||||
chains.unshift(
|
||||
h(ArrowRightCircleIcon, {
|
||||
class: 'h-4 w-4 shrink-0',
|
||||
key: `arrow-${index}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: `flex items-center ${isReverse && 'flex-row-reverse justify-end'} gap-1`,
|
||||
},
|
||||
chains,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.Outbound),
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.Outbound,
|
||||
accessorFn: (original) => original.chains[0],
|
||||
cell: ({ row }) => {
|
||||
return h(ProxyName, { name: row.original.chains[0] })
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.ConnectTime),
|
||||
enableGrouping: false,
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.ConnectTime,
|
||||
accessorFn: (original) => fromNow(original.start),
|
||||
sortingFn: (prev, next) =>
|
||||
dayjs(next.original.start).valueOf() - dayjs(prev.original.start).valueOf(),
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.DlSpeed),
|
||||
enableGrouping: false,
|
||||
sortDescFirst: true,
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.DlSpeed,
|
||||
accessorFn: (original) => `${prettyBytesHelper(original.downloadSpeed)}/s`,
|
||||
sortingFn: (prev, next) => prev.original.downloadSpeed - next.original.downloadSpeed,
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.UlSpeed),
|
||||
enableGrouping: false,
|
||||
sortDescFirst: true,
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.UlSpeed,
|
||||
accessorFn: (original) => `${prettyBytesHelper(original.uploadSpeed)}/s`,
|
||||
sortingFn: (prev, next) => prev.original.uploadSpeed - next.original.uploadSpeed,
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.Download),
|
||||
enableGrouping: false,
|
||||
sortDescFirst: true,
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.Download,
|
||||
accessorFn: (original) => prettyBytesHelper(original.download),
|
||||
sortingFn: (prev, next) => prev.original.download - next.original.download,
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.Upload),
|
||||
enableGrouping: false,
|
||||
sortDescFirst: true,
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.Upload,
|
||||
accessorFn: (original) => prettyBytesHelper(original.upload),
|
||||
sortingFn: (prev, next) => prev.original.upload - next.original.upload,
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.SourceIP),
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.SourceIP,
|
||||
accessorFn: (original) => {
|
||||
return getIPLabelFromMap(original.metadata.sourceIP)
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.SourcePort),
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.SourcePort,
|
||||
accessorFn: (original) => original.metadata.sourcePort,
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.Destination),
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.Destination,
|
||||
accessorFn: getDestinationFromConnection,
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.DestinationType),
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.DestinationType,
|
||||
accessorFn: getDestinationTypeFromConnection,
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.RemoteAddress),
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.RemoteAddress,
|
||||
accessorFn: (original) => original.metadata.remoteDestination || '-',
|
||||
},
|
||||
{
|
||||
header: () => t(CONNECTIONS_TABLE_ACCESSOR_KEY.InboundUser),
|
||||
id: CONNECTIONS_TABLE_ACCESSOR_KEY.InboundUser,
|
||||
accessorFn: getInboundUserFromConnection,
|
||||
},
|
||||
]
|
||||
|
||||
const grouping = useStorage<GroupingState>('config/table-grouping', [])
|
||||
const expanded = useStorage<ExpandedState>('config/table-expanded', {})
|
||||
const sorting = useStorage<SortingState>('config/table-sorting', [])
|
||||
const columnPinning = useStorage<ColumnPinningState>('config/table-column-pinning', {
|
||||
left: [],
|
||||
right: [],
|
||||
})
|
||||
|
||||
const tanstackTable = useVueTable({
|
||||
get data() {
|
||||
return renderConnections.value
|
||||
},
|
||||
columns,
|
||||
columnResizeMode: 'onChange',
|
||||
columnResizeDirection: 'ltr',
|
||||
state: {
|
||||
get columnOrder() {
|
||||
return connectionTableColumns.value
|
||||
},
|
||||
get columnVisibility() {
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.values(CONNECTIONS_TABLE_ACCESSOR_KEY).map((key) => [key, false]),
|
||||
),
|
||||
...Object.fromEntries(
|
||||
connectionTableColumns.value
|
||||
.filter(
|
||||
(key) =>
|
||||
key !== CONNECTIONS_TABLE_ACCESSOR_KEY.Close ||
|
||||
connectionTabShow.value !== CONNECTION_TAB_TYPE.CLOSED,
|
||||
)
|
||||
.map((key) => [key, true]),
|
||||
),
|
||||
}
|
||||
},
|
||||
get grouping() {
|
||||
return grouping.value
|
||||
},
|
||||
get expanded() {
|
||||
return expanded.value
|
||||
},
|
||||
get sorting() {
|
||||
return sorting.value
|
||||
},
|
||||
get columnSizing() {
|
||||
return columnWidthMap.value
|
||||
},
|
||||
get columnPinning() {
|
||||
return columnPinning.value
|
||||
},
|
||||
},
|
||||
onGroupingChange: (updater) => {
|
||||
if (isFunction(updater)) {
|
||||
grouping.value = updater(grouping.value)
|
||||
} else {
|
||||
grouping.value = updater
|
||||
}
|
||||
},
|
||||
onExpandedChange: (updater) => {
|
||||
if (isFunction(updater)) {
|
||||
expanded.value = updater(expanded.value)
|
||||
}
|
||||
},
|
||||
onSortingChange: (updater) => {
|
||||
if (isFunction(updater)) {
|
||||
sorting.value = updater(sorting.value)
|
||||
} else {
|
||||
sorting.value = updater
|
||||
}
|
||||
},
|
||||
onColumnSizingChange: (updater) => {
|
||||
if (isFunction(updater)) {
|
||||
columnWidthMap.value = updater(columnWidthMap.value) as Record<
|
||||
CONNECTIONS_TABLE_ACCESSOR_KEY,
|
||||
number
|
||||
>
|
||||
} else {
|
||||
columnWidthMap.value = updater as Record<CONNECTIONS_TABLE_ACCESSOR_KEY, number>
|
||||
}
|
||||
},
|
||||
onColumnPinningChange: (updater) => {
|
||||
if (isFunction(updater)) {
|
||||
columnPinning.value = updater(columnPinning.value)
|
||||
} else {
|
||||
columnPinning.value = updater
|
||||
}
|
||||
},
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getGroupedRowModel: getGroupedRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
const rows = computed(() => {
|
||||
return tanstackTable.getRowModel().rows
|
||||
})
|
||||
|
||||
const parentRef = ref<HTMLElement | null>(null)
|
||||
const rowVirtualizerOptions = computed(() => {
|
||||
return {
|
||||
count: rows.value.length,
|
||||
getScrollElement: () => parentRef.value,
|
||||
estimateSize: () => 36,
|
||||
overscan: 24,
|
||||
}
|
||||
})
|
||||
|
||||
const rowVirtualizer = useVirtualizer(rowVirtualizerOptions)
|
||||
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems())
|
||||
const totalSize = computed(() => rowVirtualizer.value.getTotalSize() + 24)
|
||||
|
||||
const classMap = {
|
||||
[TABLE_SIZE.SMALL]: 'table-xs',
|
||||
[TABLE_SIZE.LARGE]: 'table-sm',
|
||||
}
|
||||
const sizeOfTable = computed(() => {
|
||||
return classMap[tableSize.value]
|
||||
})
|
||||
|
||||
const handlerClickRow = (row: Row<Connection>) => {
|
||||
if (isDragging.value) return
|
||||
|
||||
if (row.getIsGrouped()) {
|
||||
if (row.getCanExpand()) {
|
||||
row.getToggleExpandedHandler()()
|
||||
}
|
||||
} else {
|
||||
handlerInfo(row.original)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePinColumn = (column: Column<Connection, unknown>) => {
|
||||
if (column.getIsPinned() === 'left') {
|
||||
column.pin(false)
|
||||
} else {
|
||||
const currentPinning = columnPinning.value.left || []
|
||||
|
||||
currentPinning.forEach((pinnedColumnId: string) => {
|
||||
if (pinnedColumnId !== column.id) {
|
||||
const pinnedColumn = tanstackTable.getColumn(pinnedColumnId)
|
||||
if (pinnedColumn) {
|
||||
pinnedColumn.pin(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
column.pin('left')
|
||||
}
|
||||
}
|
||||
|
||||
const isDragging = ref(false)
|
||||
const isMouseDown = ref(false)
|
||||
const DRAG_THRESHOLD = Math.pow(3, 2)
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return // 只处理左键
|
||||
isMouseDown.value = true
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isMouseDown.value || !parentRef.value) return
|
||||
|
||||
const deltaX = e.movementX
|
||||
const deltaY = e.movementY
|
||||
|
||||
// 检查是否超过拖动阈值
|
||||
if (!isDragging.value && Math.pow(deltaX, 2) + Math.pow(deltaY, 2) > DRAG_THRESHOLD) {
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
if (isDragging.value) {
|
||||
parentRef.value.scrollLeft -= deltaX
|
||||
parentRef.value.scrollTop -= deltaY
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// 延迟重置拖动状态,以防止在拖动结束后立即触发点击事件
|
||||
if (isDragging.value) {
|
||||
setTimeout(() => {
|
||||
isDragging.value = false
|
||||
}, 100)
|
||||
}
|
||||
isMouseDown.value = false
|
||||
}
|
||||
|
||||
// 复制功能
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
showNotification({
|
||||
content: 'copySuccess',
|
||||
type: 'alert-success',
|
||||
timeout: 2000,
|
||||
})
|
||||
} catch {
|
||||
// 降级处理
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
showNotification({
|
||||
content: 'copySuccess',
|
||||
type: 'alert-success',
|
||||
timeout: 2000,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCellRightClick = (
|
||||
event: MouseEvent,
|
||||
cell: { column: { id: string }; getValue: () => unknown },
|
||||
) => {
|
||||
event.preventDefault()
|
||||
|
||||
const cellValue = cell.getValue()
|
||||
|
||||
if (cellValue && cellValue !== '-') {
|
||||
copyToClipboard(String(cellValue))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
th .resizer {
|
||||
@apply opacity-0;
|
||||
}
|
||||
th:hover .resizer {
|
||||
@apply opacity-100;
|
||||
}
|
||||
</style>
|
||||
47
src/components/logs/LogsCard.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="card hover:bg-base-200 block p-2 text-sm break-all">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<div :style="{ minWidth: `${(seqWithPadding.length + 1) * 0.62}em` }">
|
||||
{{ seqWithPadding }}.
|
||||
</div>
|
||||
<span class="badge badge-sm text-main min-w-14">
|
||||
{{ log.time }}
|
||||
</span>
|
||||
<span
|
||||
class="badge badge-sm min-w-17"
|
||||
:class="textColorMapForType[log.type as keyof typeof textColorMapForType]"
|
||||
>
|
||||
{{ log.type }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="leading-6 max-md:mt-2 max-md:block md:ml-2">{{ log.payload }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBounceOnVisible } from '@/composables/bouncein'
|
||||
import { LOG_LEVEL } from '@/constant'
|
||||
import type { LogWithSeq } from '@/types'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
log: LogWithSeq
|
||||
}>()
|
||||
|
||||
const seqWithPadding = computed(() => {
|
||||
return props.log.seq.toString().padStart(2, '0')
|
||||
})
|
||||
|
||||
const textColorMapForType = {
|
||||
[LOG_LEVEL.Trace]: 'text-success',
|
||||
[LOG_LEVEL.Debug]: 'text-accent',
|
||||
[LOG_LEVEL.Info]: 'text-info',
|
||||
[LOG_LEVEL.Warning]: 'text-warning',
|
||||
[LOG_LEVEL.Error]: 'text-error',
|
||||
[LOG_LEVEL.Fatal]: 'text-error',
|
||||
[LOG_LEVEL.Panic]: 'text-error',
|
||||
}
|
||||
|
||||
useBounceOnVisible()
|
||||
</script>
|
||||
216
src/components/overview/BasicCharts.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div class="relative h-28 w-full overflow-hidden">
|
||||
<div
|
||||
ref="chart"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
<span
|
||||
class="border-b-primary/30 border-t-primary/60 border-l-info/30 border-r-info/60 text-base-content/10 bg-base-100/70 hidden"
|
||||
ref="colorRef"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs absolute right-1 bottom-0"
|
||||
@click="isPaused = !isPaused"
|
||||
>
|
||||
<component
|
||||
:is="!isPaused ? PauseCircleIcon : PlayCircleIcon"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isMiddleScreen } from '@/helper/utils'
|
||||
import { font, theme } from '@/store/settings'
|
||||
import { PauseCircleIcon, PlayCircleIcon } from '@heroicons/vue/24/outline'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { debounce } from 'lodash'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer])
|
||||
|
||||
const props = defineProps<{
|
||||
data: { name: string; color?: number; data: { name: number; value: number }[] }[]
|
||||
labelFormatter: (value: number) => string
|
||||
toolTipFormatter: (value: ToolTipParams[]) => string
|
||||
min: number
|
||||
}>()
|
||||
|
||||
const colorRef = ref()
|
||||
const chart = ref()
|
||||
const isPaused = ref(false)
|
||||
const colorSet = {
|
||||
primary30: '',
|
||||
primary60: '',
|
||||
info30: '',
|
||||
info60: '',
|
||||
baseContent10: '',
|
||||
baseContent: '',
|
||||
base70: '',
|
||||
}
|
||||
|
||||
let fontFamily = ''
|
||||
|
||||
const updateColorSet = () => {
|
||||
const colorStyle = getComputedStyle(colorRef.value)
|
||||
|
||||
colorSet.baseContent = colorStyle.getPropertyValue('--color-base-content').trim()
|
||||
colorSet.base70 = colorStyle.backgroundColor
|
||||
colorSet.baseContent10 = colorStyle.color
|
||||
colorSet.primary30 = colorStyle.borderTopColor
|
||||
colorSet.primary60 = colorStyle.borderBottomColor
|
||||
colorSet.info30 = colorStyle.borderLeftColor
|
||||
colorSet.info60 = colorStyle.borderRightColor
|
||||
}
|
||||
const updateFontFamily = () => {
|
||||
const baseColorStyle = getComputedStyle(colorRef.value)
|
||||
|
||||
fontFamily = baseColorStyle.fontFamily
|
||||
}
|
||||
|
||||
const options = computed(() => {
|
||||
return {
|
||||
legend: {
|
||||
bottom: 0,
|
||||
data: props.data.map((item) => item.name),
|
||||
textStyle: {
|
||||
color: colorSet.baseContent,
|
||||
fontFamily,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: 60,
|
||||
top: 15,
|
||||
right: 8,
|
||||
bottom: 25,
|
||||
},
|
||||
tooltip: {
|
||||
show: true,
|
||||
trigger: 'axis',
|
||||
backgroundColor: colorSet.base70,
|
||||
borderColor: colorSet.base70,
|
||||
confine: true,
|
||||
padding: [0, 5],
|
||||
textStyle: {
|
||||
color: colorSet.baseContent,
|
||||
fontFamily,
|
||||
},
|
||||
formatter: props.toolTipFormatter,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
axisLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitNumber: 4,
|
||||
max: (value: { max: number }) => {
|
||||
return Math.max(value.max, props.min)
|
||||
},
|
||||
axisLine: { show: false },
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
color: colorSet.baseContent10,
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
align: 'left',
|
||||
padding: [0, 0, 0, -45],
|
||||
formatter: props.labelFormatter,
|
||||
color: colorSet.baseContent,
|
||||
fontFamily,
|
||||
},
|
||||
},
|
||||
series: props.data.map((item, index) => {
|
||||
const seriesColor = index === props.data.length - 1 ? colorSet.primary60 : colorSet.info60
|
||||
const areaColor = index === props.data.length - 1 ? colorSet.primary30 : colorSet.info30
|
||||
|
||||
return {
|
||||
name: item.name,
|
||||
symbol: 'none',
|
||||
emphasis: {
|
||||
disabled: true,
|
||||
},
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
},
|
||||
data: item.data,
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: seriesColor,
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: areaColor,
|
||||
},
|
||||
]),
|
||||
},
|
||||
type: 'line',
|
||||
color: seriesColor,
|
||||
smooth: true,
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
let myChart: echarts.ECharts | null = null
|
||||
let touchEndHandler: ((e: TouchEvent) => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
updateColorSet()
|
||||
updateFontFamily()
|
||||
|
||||
watch(theme, updateColorSet)
|
||||
watch(font, updateFontFamily)
|
||||
|
||||
myChart = echarts.init(chart.value)
|
||||
|
||||
myChart.setOption(options.value)
|
||||
|
||||
watch(options, () => {
|
||||
if (isPaused.value) {
|
||||
return
|
||||
}
|
||||
myChart?.setOption(options.value)
|
||||
})
|
||||
|
||||
const { width } = useElementSize(chart)
|
||||
const resize = debounce(() => {
|
||||
myChart?.resize()
|
||||
}, 100)
|
||||
|
||||
watch(width, resize)
|
||||
|
||||
// 移动端:松手后自动隐藏 tooltip
|
||||
if (isMiddleScreen.value && chart.value) {
|
||||
touchEndHandler = () => {
|
||||
if (myChart) {
|
||||
myChart.dispatchAction({ type: 'hideTip' })
|
||||
}
|
||||
}
|
||||
chart.value.addEventListener('touchend', touchEndHandler)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart.value && touchEndHandler) {
|
||||
chart.value.removeEventListener('touchend', touchEndHandler)
|
||||
}
|
||||
if (myChart) {
|
||||
myChart.dispose()
|
||||
myChart = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
23
src/components/overview/ChartsCard.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<!-- overview -->
|
||||
<div class="card w-full">
|
||||
<div class="card-title px-4 pt-4">
|
||||
{{ $t('overview') }}
|
||||
</div>
|
||||
<div class="card-body gap-4">
|
||||
<StatisticsStats type="overview" />
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<SpeedCharts class="xl:h-64" />
|
||||
<MemoryCharts class="xl:h-64" />
|
||||
<ConnectionsCharts class="xl:h-64" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ConnectionsCharts from '@/components/overview/ConnectionsCharts.vue'
|
||||
import MemoryCharts from '@/components/overview/MemoryCharts.vue'
|
||||
import SpeedCharts from '@/components/overview/SpeedCharts.vue'
|
||||
import StatisticsStats from '@/components/overview/StatisticsStats.vue'
|
||||
</script>
|
||||
439
src/components/overview/ConnectionHistory.vue
Normal file
@@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<div class="card w-full backdrop-blur-none!">
|
||||
<div class="card-title need-blur flex items-center justify-between px-4 pt-4">
|
||||
<div class="flex w-full items-center gap-4 max-sm:flex-col max-sm:items-start">
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
{{ $t('totalConnections') }}
|
||||
|
||||
<button
|
||||
class="btn btn-circle btn-sm"
|
||||
@click="showClearDialog = true"
|
||||
>
|
||||
<TrashIcon class="h-4 w-4" />
|
||||
</button>
|
||||
<QuestionMarkCircleIcon
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@mouseenter="showTip($event, totalConnectionsTip)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 font-normal max-sm:flex-col max-sm:items-start">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">{{ $t('aggregateBy') }}</span>
|
||||
<select
|
||||
v-model="aggregationType"
|
||||
class="select select-bordered select-sm w-32"
|
||||
>
|
||||
<option :value="ConnectionHistoryType.SourceIP">
|
||||
{{ $t('aggregateBySourceIP') }}
|
||||
</option>
|
||||
<option :value="ConnectionHistoryType.Destination">
|
||||
{{ $t('aggregateByDestination') }}
|
||||
</option>
|
||||
<option :value="ConnectionHistoryType.Process">{{ $t('aggregateByProcess') }}</option>
|
||||
<option :value="ConnectionHistoryType.Outbound">
|
||||
{{ $t('aggregateByOutbound') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">{{ $t('autoCleanupInterval') }}</span>
|
||||
<select
|
||||
v-model="autoCleanupInterval"
|
||||
class="select select-bordered select-sm w-28"
|
||||
>
|
||||
<option :value="AutoCleanupInterval.Never">
|
||||
{{ $t('autoCleanupIntervalNever') }}
|
||||
</option>
|
||||
<option :value="AutoCleanupInterval.Week">{{ $t('autoCleanupIntervalWeek') }}</option>
|
||||
<option :value="AutoCleanupInterval.Month">
|
||||
{{ $t('autoCleanupIntervalMonth') }}
|
||||
</option>
|
||||
<option :value="AutoCleanupInterval.Quarter">
|
||||
{{ $t('autoCleanupIntervalQuarter') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body need-blur gap-0! p-0!">
|
||||
<div class="px-4 py-4">
|
||||
<div
|
||||
class="stats stats-vertical sm:stats-horizontal bg-base-200 w-full gap-2 shadow max-md:grid max-md:grid-cols-2"
|
||||
>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">{{ aggregateSourceLabel }}</div>
|
||||
<div class="stat-value text-lg">{{ aggregateSourceCount }}</div>
|
||||
</div>
|
||||
<div class="stat md:hidden"></div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">{{ t('download') }}</div>
|
||||
<div class="stat-value text-lg">{{ prettyBytesHelper(totalStats.download) }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">{{ t('upload') }}</div>
|
||||
<div class="stat-value text-lg">{{ prettyBytesHelper(totalStats.upload) }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">{{ t('totalTraffic') }}</div>
|
||||
<div class="stat-value text-lg">
|
||||
{{ prettyBytesHelper(totalStats.download + totalStats.upload) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">{{ t('connectionCount') }}</div>
|
||||
<div class="stat-value text-lg">{{ totalStats.count.toString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="parentRef"
|
||||
class="h-96 overflow-auto"
|
||||
@touchstart.passive.stop
|
||||
@touchmove.passive.stop
|
||||
@touchend.passive.stop
|
||||
>
|
||||
<div :style="{ height: `${totalSize}px` }">
|
||||
<table class="table-sm table-zebra table w-full rounded-none">
|
||||
<thead class="bg-base-200 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th
|
||||
v-for="header in tanstackTable.getHeaderGroups()[0]?.headers"
|
||||
:key="header.id"
|
||||
class="cursor-pointer select-none"
|
||||
@click="header.column.getToggleSortingHandler()?.($event)"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
<ArrowUpCircleIcon
|
||||
v-if="header.column.getIsSorted() === 'asc'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<ArrowDownCircleIcon
|
||||
v-if="header.column.getIsSorted() === 'desc'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(virtualRow, index) in virtualRows"
|
||||
:key="virtualRow.key.toString()"
|
||||
:style="{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start - index * virtualRow.size}px)`,
|
||||
}"
|
||||
class="hover:bg-primary! hover:text-primary-content whitespace-nowrap"
|
||||
>
|
||||
<td
|
||||
v-for="cell in rows[virtualRow.index].getVisibleCells()"
|
||||
:key="cell.id"
|
||||
class="text-sm"
|
||||
>
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<DialogWrapper
|
||||
v-model="showClearDialog"
|
||||
:title="$t('clearConnectionHistory')"
|
||||
>
|
||||
<div class="flex flex-col gap-4 p-2">
|
||||
<p class="text-sm">
|
||||
{{ $t('clearConnectionHistoryConfirm') }}
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="showClearDialog = false"
|
||||
>
|
||||
{{ $t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-error btn-sm"
|
||||
@click="handleClearHistory"
|
||||
>
|
||||
{{ $t('confirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ConnectionHistoryType, clearConnectionHistoryFromIndexedDB } from '@/helper/indexeddb'
|
||||
import { showNotification } from '@/helper/notification'
|
||||
import { getIPLabelFromMap } from '@/helper/sourceip'
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import { prettyBytesHelper } from '@/helper/utils'
|
||||
import {
|
||||
aggregateConnections,
|
||||
aggregatedDataMap,
|
||||
initAggregatedDataMap,
|
||||
mergeAggregatedData,
|
||||
} from '@/store/connHistory'
|
||||
import { activeConnections } from '@/store/connections'
|
||||
import {
|
||||
ArrowDownCircleIcon,
|
||||
ArrowUpCircleIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
} from '@tanstack/vue-table'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, h, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import DialogWrapper from '../common/DialogWrapper.vue'
|
||||
import ProxyName from '../proxies/ProxyName.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { showTip } = useTooltip()
|
||||
|
||||
enum AutoCleanupInterval {
|
||||
Never = 'never',
|
||||
Week = 'week',
|
||||
Month = 'month',
|
||||
Quarter = 'quarter',
|
||||
}
|
||||
|
||||
interface ConnectionHistoryData {
|
||||
key: string
|
||||
download: number
|
||||
upload: number
|
||||
count: number
|
||||
}
|
||||
|
||||
const aggregationType = useStorage<ConnectionHistoryType>(
|
||||
'cache/connection-history-aggregation-type',
|
||||
ConnectionHistoryType.SourceIP,
|
||||
)
|
||||
const historicalData = computed(() => aggregatedDataMap.value[aggregationType.value])
|
||||
const aggregatedData = computed<ConnectionHistoryData[]>(() => {
|
||||
const currentData = aggregateConnections(activeConnections.value, aggregationType.value)
|
||||
|
||||
return mergeAggregatedData(historicalData.value, currentData)
|
||||
})
|
||||
|
||||
const totalStats = computed(() => {
|
||||
return aggregatedData.value.reduce(
|
||||
(acc, item) => {
|
||||
acc.download += item.download
|
||||
acc.upload += item.upload
|
||||
acc.count += item.count
|
||||
return acc
|
||||
},
|
||||
{ download: 0, upload: 0, count: 0 },
|
||||
)
|
||||
})
|
||||
|
||||
const aggregateSourceCount = computed(() => aggregatedData.value.length)
|
||||
|
||||
const aggregateSourceLabel = computed(() => {
|
||||
if (aggregationType.value === ConnectionHistoryType.SourceIP) {
|
||||
return t('sourceIP')
|
||||
} else if (aggregationType.value === ConnectionHistoryType.Destination) {
|
||||
return t('host')
|
||||
} else if (aggregationType.value === ConnectionHistoryType.Process) {
|
||||
return t('process')
|
||||
} else {
|
||||
return t('outbound')
|
||||
}
|
||||
})
|
||||
|
||||
const columns = computed<ColumnDef<ConnectionHistoryData>[]>(() => {
|
||||
const keyColumn: ColumnDef<ConnectionHistoryData> = {
|
||||
header: () => aggregateSourceLabel.value,
|
||||
id: 'key',
|
||||
accessorFn: (row) => row.key,
|
||||
cell: ({ row }) => {
|
||||
if (aggregationType.value === ConnectionHistoryType.SourceIP) {
|
||||
return getIPLabelFromMap(row.original.key)
|
||||
} else if (aggregationType.value === ConnectionHistoryType.Destination) {
|
||||
return row.original.key
|
||||
} else if (aggregationType.value === ConnectionHistoryType.Process) {
|
||||
return row.original.key
|
||||
} else {
|
||||
return h(ProxyName, { name: row.original.key })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return [
|
||||
keyColumn,
|
||||
{
|
||||
header: () => t('download'),
|
||||
id: 'download',
|
||||
accessorFn: (row) => row.download,
|
||||
cell: ({ row }) => prettyBytesHelper(row.original.download),
|
||||
sortingFn: (prev, next) => prev.original.download - next.original.download,
|
||||
sortDescFirst: true,
|
||||
},
|
||||
{
|
||||
header: () => t('upload'),
|
||||
id: 'upload',
|
||||
accessorFn: (row) => row.upload,
|
||||
cell: ({ row }) => prettyBytesHelper(row.original.upload),
|
||||
sortingFn: (prev, next) => prev.original.upload - next.original.upload,
|
||||
sortDescFirst: true,
|
||||
},
|
||||
{
|
||||
header: () => t('totalTraffic'),
|
||||
id: 'total',
|
||||
accessorFn: (row) => row.download + row.upload,
|
||||
cell: ({ row }) => prettyBytesHelper(row.original.download + row.original.upload),
|
||||
sortingFn: (prev, next) =>
|
||||
prev.original.download +
|
||||
prev.original.upload -
|
||||
(next.original.download + next.original.upload),
|
||||
sortDescFirst: true,
|
||||
},
|
||||
{
|
||||
header: () => t('connectionCount'),
|
||||
id: 'count',
|
||||
accessorFn: (row) => row.count,
|
||||
cell: ({ row }) => row.original.count.toString(),
|
||||
sortingFn: (prev, next) => prev.original.count - next.original.count,
|
||||
sortDescFirst: true,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const sorting = useStorage<SortingState>('cache/connection-history-sorting', [
|
||||
{ id: 'download', desc: true },
|
||||
])
|
||||
|
||||
const tanstackTable = useVueTable({
|
||||
get data() {
|
||||
return aggregatedData.value
|
||||
},
|
||||
get columns() {
|
||||
return columns.value
|
||||
},
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value
|
||||
},
|
||||
},
|
||||
onSortingChange: (updater) => {
|
||||
if (typeof updater === 'function') {
|
||||
sorting.value = updater(sorting.value)
|
||||
} else {
|
||||
sorting.value = updater
|
||||
}
|
||||
},
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})
|
||||
|
||||
const rows = computed(() => {
|
||||
return tanstackTable.getRowModel().rows
|
||||
})
|
||||
|
||||
const parentRef = ref<HTMLElement | null>(null)
|
||||
const rowVirtualizerOptions = computed(() => {
|
||||
return {
|
||||
count: rows.value.length,
|
||||
getScrollElement: () => parentRef.value,
|
||||
estimateSize: () => 36,
|
||||
overscan: 10,
|
||||
}
|
||||
})
|
||||
|
||||
const rowVirtualizer = useVirtualizer(rowVirtualizerOptions)
|
||||
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems())
|
||||
const totalSize = computed(() => rowVirtualizer.value.getTotalSize() + 24)
|
||||
|
||||
const showClearDialog = ref(false)
|
||||
const autoCleanupInterval = useStorage<AutoCleanupInterval>(
|
||||
'config/connection-history-auto-cleanup-interval',
|
||||
AutoCleanupInterval.Month,
|
||||
)
|
||||
const startTime = useStorage<number>('cache/connection-history-stats-start-time', Date.now())
|
||||
const totalConnectionsTip = computed(() => {
|
||||
const dayjsTime = dayjs(startTime.value)
|
||||
|
||||
return t('totalConnectionsTip', {
|
||||
statsStartTime: `${dayjsTime.format('YYYY-MM-DD HH:mm')} (${dayjsTime.fromNow()})`,
|
||||
})
|
||||
})
|
||||
const getCleanupIntervalMs = (interval: AutoCleanupInterval): number => {
|
||||
switch (interval) {
|
||||
case AutoCleanupInterval.Week:
|
||||
return 7 * 24 * 60 * 60 * 1000
|
||||
case AutoCleanupInterval.Month:
|
||||
return 30 * 24 * 60 * 60 * 1000
|
||||
case AutoCleanupInterval.Quarter:
|
||||
return 90 * 24 * 60 * 60 * 1000
|
||||
case AutoCleanupInterval.Never:
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const checkAndPerformAutoCleanup = async () => {
|
||||
if (autoCleanupInterval.value === AutoCleanupInterval.Never) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const intervalMs = getCleanupIntervalMs(autoCleanupInterval.value)
|
||||
const timeSinceLastCleanup = now - startTime.value
|
||||
|
||||
if (timeSinceLastCleanup >= intervalMs) {
|
||||
try {
|
||||
await clearConnectionHistoryFromIndexedDB()
|
||||
await initAggregatedDataMap()
|
||||
startTime.value = now
|
||||
} catch (error) {
|
||||
console.error('Failed to perform auto cleanup:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearHistory = async () => {
|
||||
try {
|
||||
await clearConnectionHistoryFromIndexedDB()
|
||||
await initAggregatedDataMap()
|
||||
startTime.value = Date.now()
|
||||
showClearDialog.value = false
|
||||
showNotification({
|
||||
content: t('clearConnectionHistorySuccess'),
|
||||
type: 'alert-success',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to clear connection history:', error)
|
||||
showNotification({
|
||||
content: `${t('saveFailed')}: ${error}`,
|
||||
type: 'alert-error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkAndPerformAutoCleanup()
|
||||
})
|
||||
</script>
|
||||
82
src/components/overview/ConnectionStatus.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="bg-base-200/50 relative h-28 rounded-lg p-2 text-sm">
|
||||
<div class="flex h-full flex-col justify-between">
|
||||
<div>
|
||||
<span class="inline-block w-20">Baidu </span>
|
||||
:
|
||||
<span :class="getColorForLatency(Number(baiduLatency))">{{ baiduLatency }}ms </span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="inline-block w-20">Cloudflare </span>
|
||||
:
|
||||
<span :class="getColorForLatency(Number(cloudflareLatency))"
|
||||
>{{ cloudflareLatency }}ms
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="inline-block w-20">Github </span>
|
||||
:
|
||||
<span :class="getColorForLatency(Number(githubLatency))">{{ githubLatency }}ms </span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="inline-block w-20">YouTube </span>
|
||||
:
|
||||
<span :class="getColorForLatency(Number(youtubeLatency))">{{ youtubeLatency }}ms </span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-circle btn-sm absolute right-2 bottom-2"
|
||||
@click="getLatency"
|
||||
>
|
||||
<BoltIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
getBaiduLatencyAPI,
|
||||
getCloudflareLatencyAPI,
|
||||
getGithubLatencyAPI,
|
||||
getYouTubeLatencyAPI,
|
||||
} from '@/api/latency'
|
||||
import {
|
||||
baiduLatency,
|
||||
cloudflareLatency,
|
||||
githubLatency,
|
||||
youtubeLatency,
|
||||
} from '@/composables/overview'
|
||||
import { getColorForLatency } from '@/helper'
|
||||
import { autoConnectionCheck } from '@/store/settings'
|
||||
import { BoltIcon } from '@heroicons/vue/24/outline'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const getLatency = async () => {
|
||||
getBaiduLatencyAPI().then((res) => {
|
||||
baiduLatency.value = res.toFixed(0)
|
||||
})
|
||||
|
||||
getCloudflareLatencyAPI().then((res) => {
|
||||
cloudflareLatency.value = res.toFixed(0)
|
||||
})
|
||||
|
||||
getGithubLatencyAPI().then((res) => {
|
||||
githubLatency.value = res.toFixed(0)
|
||||
})
|
||||
|
||||
getYouTubeLatencyAPI().then((res) => {
|
||||
youtubeLatency.value = res.toFixed(0)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
autoConnectionCheck.value &&
|
||||
[baiduLatency, cloudflareLatency, githubLatency, youtubeLatency].some(
|
||||
(item) => item.value === '',
|
||||
)
|
||||
) {
|
||||
getLatency()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
46
src/components/overview/ConnectionsCharts.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<BasicCharts
|
||||
:data="chartsData"
|
||||
:label-formatter="labelFormatter"
|
||||
:tool-tip-formatter="tooltipFormatter"
|
||||
:min="100"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { connectionsHistory, timeSaved } from '@/store/overview'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BasicCharts from './BasicCharts.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const chartsData = computed(() => {
|
||||
return [
|
||||
{
|
||||
name: t('connections'),
|
||||
data: connectionsHistory.value,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const labelFormatter = (value: number) => {
|
||||
return ` ${value}`
|
||||
}
|
||||
const tooltipFormatter = (value: ToolTipParams[]) => {
|
||||
return value
|
||||
.map((item) => {
|
||||
// fake data
|
||||
if (item.data.name < timeSaved + 1) {
|
||||
return
|
||||
}
|
||||
return `
|
||||
<div class="flex items-center my-2 gap-1">
|
||||
<div class="w-4 h-4 rounded-full" style="background-color: ${item.color}"></div>
|
||||
${item.seriesName}
|
||||
(${dayjs(item.data.name).format('HH:mm:ss')}): ${item.data.value}
|
||||
</div>`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
</script>
|
||||
123
src/components/overview/IPCheck.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="bg-base-200/50 relative flex h-28 flex-col gap-1 rounded-lg p-2">
|
||||
<div class="grid grid-cols-[auto_auto_1fr] gap-x-2 gap-y-1">
|
||||
<div class="text-left text-sm">ipip.net</div>
|
||||
<div class="text-right text-sm">:</div>
|
||||
<div class="text-sm">
|
||||
{{ showPrivacy ? ipForChina.ipWithPrivacy[0] : ipForChina.ip[0] }}
|
||||
<span
|
||||
class="text-xs"
|
||||
v-if="ipForChina.ip[1]"
|
||||
>
|
||||
({{ showPrivacy ? ipForChina.ipWithPrivacy[1] : ipForChina.ip[1] }})
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-left text-sm">{{ IPInfoAPI }}</div>
|
||||
<div class="text-right text-sm">:</div>
|
||||
<div class="text-sm">
|
||||
{{ showPrivacy ? ipForGlobal.ipWithPrivacy[0] : ipForGlobal.ip[0] }}
|
||||
<span
|
||||
class="text-xs"
|
||||
v-if="ipForGlobal.ip[1]"
|
||||
>
|
||||
({{ showPrivacy ? ipForGlobal.ipWithPrivacy[1] : ipForGlobal.ip[1] }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute right-2 bottom-2 flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-circle btn-sm flex items-center justify-center"
|
||||
@click="showPrivacy = !showPrivacy"
|
||||
@mouseenter="handlerShowPrivacyTip"
|
||||
>
|
||||
<EyeIcon
|
||||
v-if="showPrivacy"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<EyeSlashIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-circle btn-sm"
|
||||
@click="getIPs"
|
||||
>
|
||||
<BoltIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getIPFromIpipnetAPI, getIPInfo } from '@/api/geoip'
|
||||
import { ipForChina, ipForGlobal } from '@/composables/overview'
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import { autoIPCheck, IPInfoAPI } from '@/store/settings'
|
||||
import { BoltIcon, EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/outline'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const showPrivacy = ref(false)
|
||||
const { showTip } = useTooltip()
|
||||
const handlerShowPrivacyTip = (e: Event) => {
|
||||
showTip(e, t('ipScreenshotTip'))
|
||||
}
|
||||
|
||||
const QUERYING_IP_INFO = {
|
||||
ip: [t('getting'), ''],
|
||||
ipWithPrivacy: [t('getting'), ''],
|
||||
}
|
||||
|
||||
const FAILED_IP_INFO = {
|
||||
ip: [t('testFailed'), ''],
|
||||
ipWithPrivacy: [t('testFailed'), ''],
|
||||
}
|
||||
|
||||
const getIPs = () => {
|
||||
ipForChina.value = {
|
||||
...QUERYING_IP_INFO,
|
||||
}
|
||||
ipForGlobal.value = {
|
||||
...QUERYING_IP_INFO,
|
||||
}
|
||||
getIPInfo()
|
||||
.then((res) => {
|
||||
ipForGlobal.value = {
|
||||
ipWithPrivacy: [`${res.country} ${res.organization}`, res.ip],
|
||||
ip: [`${res.country} ${res.organization}`, '***.***.***.***'],
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ipForGlobal.value = {
|
||||
...FAILED_IP_INFO,
|
||||
}
|
||||
})
|
||||
getIPFromIpipnetAPI()
|
||||
.then((res) => {
|
||||
ipForChina.value = {
|
||||
ipWithPrivacy: [res.data.location.join(' '), res.data.ip],
|
||||
ip: [`${res.data.location[0]} ** ** **`, '***.***.***.***'],
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
ipForChina.value = {
|
||||
...FAILED_IP_INFO,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
watch(IPInfoAPI, () => {
|
||||
if ([ipForChina, ipForGlobal].some((item) => item.value.ip.length !== 0)) {
|
||||
getIPs()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (autoIPCheck.value && [ipForChina, ipForGlobal].some((item) => item.value.ip.length === 0)) {
|
||||
getIPs()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
40
src/components/overview/MemoryCharts.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<BasicCharts
|
||||
:data="chartsData"
|
||||
:label-formatter="labelFormatter"
|
||||
:tool-tip-formatter="tooltipFormatter"
|
||||
:min="100 * 1024 * 1024"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getToolTipForParams } from '@/helper'
|
||||
import { prettyBytesHelper } from '@/helper/utils'
|
||||
import { memoryHistory } from '@/store/overview'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BasicCharts from './BasicCharts.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const chartsData = computed(() => {
|
||||
return [
|
||||
{
|
||||
name: t('memoryUsage'),
|
||||
data: memoryHistory.value,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const labelFormatter = (value: number) => {
|
||||
return `${prettyBytesHelper(value, {
|
||||
maximumFractionDigits: 0,
|
||||
binary: true,
|
||||
})}`
|
||||
}
|
||||
const tooltipFormatter = (value: ToolTipParams[]) => {
|
||||
return getToolTipForParams(value[0], {
|
||||
binary: true,
|
||||
suffix: '',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
18
src/components/overview/NetworkCard.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="card w-full">
|
||||
<div class="card-title px-4 pt-4">
|
||||
{{ $t('networkInfo') }}
|
||||
</div>
|
||||
<div class="card-body gap-4">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
|
||||
<IPCheck />
|
||||
<ConnectionStatus />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ConnectionStatus from '@/components/overview/ConnectionStatus.vue'
|
||||
import IPCheck from '@/components/overview/IPCheck.vue'
|
||||
</script>
|
||||
64
src/components/overview/OverviewCardSettingsDialog.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<DialogWrapper
|
||||
v-model="isOpen"
|
||||
:title="$t('overviewCardSettings')"
|
||||
>
|
||||
<div class="flex flex-col text-sm">
|
||||
<Draggable
|
||||
v-model="orderedCards"
|
||||
:animation="150"
|
||||
ghost-class="ghost"
|
||||
handle=".drag-handle"
|
||||
:item-key="(card: string) => card"
|
||||
>
|
||||
<template #item="{ element: cardKey }">
|
||||
<div class="setting-item mb-2">
|
||||
<Bars3Icon class="drag-handle text-base-content/50 h-5 w-5 shrink-0 cursor-move" />
|
||||
<div class="setting-item-label">
|
||||
{{ $t(cardKeyToLabelMap[cardKey.card] || cardKey.card) }}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
:checked="cardKey.visible"
|
||||
@change="cardKey.visible = !cardKey.visible"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DialogWrapper from '@/components/common/DialogWrapper.vue'
|
||||
import { OVERVIEW_CARD } from '@/constant'
|
||||
import { overviewCardOrder } from '@/store/settings'
|
||||
import { Bars3Icon } from '@heroicons/vue/24/outline'
|
||||
import { computed } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
const isOpen = defineModel<boolean>({ required: true })
|
||||
|
||||
const cardKeyToLabelMap: Record<string, string> = {
|
||||
ChartsCard: 'chartsCard',
|
||||
NetworkCard: 'networkCard',
|
||||
ProviderTrafficOverview: 'providerTrafficOverview',
|
||||
TopologyCharts: 'topologyCharts',
|
||||
ConnectionHistory: 'connectionHistory',
|
||||
RuleHitCountCard: 'ruleHitCountCard',
|
||||
}
|
||||
|
||||
const orderedCards = computed({
|
||||
get: () => overviewCardOrder.value,
|
||||
set: (newOrder: { card: OVERVIEW_CARD; visible: boolean }[]) => {
|
||||
overviewCardOrder.value = newOrder
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
154
src/components/overview/ProviderTrafficOverview.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div
|
||||
class="card w-full"
|
||||
v-if="hasProvidersWithTraffic"
|
||||
>
|
||||
<div class="card-title px-4 pt-4">
|
||||
{{ $t('providerTrafficOverview') }}
|
||||
</div>
|
||||
<div
|
||||
class="card-body grid max-h-128 gap-2 overflow-y-auto"
|
||||
:style="
|
||||
hasMultipleProvidersWithTraffic
|
||||
? `grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));`
|
||||
: 'grid-template-columns: 1fr;'
|
||||
"
|
||||
>
|
||||
<!-- 总流量 -->
|
||||
<div
|
||||
class="bg-base-200/50 flex flex-col gap-2 rounded-lg p-2"
|
||||
v-if="hasMultipleProvidersWithTraffic"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-lg font-medium">
|
||||
{{ $t('totalTraffic') }}
|
||||
</div>
|
||||
<div class="text-base-content/70 text-sm">{{ totalPercentage }}%</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<progress
|
||||
class="progress h-2 w-full"
|
||||
:class="getProgressColor(totalPercentage)"
|
||||
:value="totalPercentage"
|
||||
max="100"
|
||||
></progress>
|
||||
</div>
|
||||
<div class="text-base-content/60 flex items-center justify-between text-sm">
|
||||
<div>{{ $t('remainingTraffic') }}: {{ totalRemainingStr }}</div>
|
||||
<div>{{ $t('usedTraffic') }}: {{ totalUsedStr }} / {{ totalTotalStr }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 各提供商流量 -->
|
||||
<div
|
||||
v-for="provider in providersWithTraffic"
|
||||
:key="provider.name"
|
||||
class="bg-base-200/50 flex flex-col gap-2 rounded-lg p-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-lg font-medium">
|
||||
{{ provider.name }}
|
||||
</div>
|
||||
<div class="text-base-content/70 text-sm">{{ provider.percentage }}%</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<progress
|
||||
class="progress h-2 w-full"
|
||||
:class="getProgressColor(provider.percentage)"
|
||||
:value="provider.percentage"
|
||||
max="100"
|
||||
></progress>
|
||||
</div>
|
||||
<div class="text-base-content/60 flex items-center justify-between text-sm">
|
||||
<div>{{ $t('remainingTraffic') }}: {{ provider.remainingStr }}</div>
|
||||
<div>{{ $t('usedTraffic') }}: {{ provider.usedStr }} / {{ provider.totalStr }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { prettyBytesHelper } from '@/helper/utils'
|
||||
import { proxyProviederList } from '@/store/proxies'
|
||||
import { toFinite } from 'lodash'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface ProviderTrafficInfo {
|
||||
name: string
|
||||
used: number
|
||||
remaining: number
|
||||
total: number
|
||||
percentage: number
|
||||
usedStr: string
|
||||
remainingStr: string
|
||||
totalStr: string
|
||||
}
|
||||
|
||||
const providersWithTraffic = computed<ProviderTrafficInfo[]>(() => {
|
||||
return proxyProviederList.value
|
||||
.filter((provider) => {
|
||||
const info = provider.subscriptionInfo
|
||||
return info && info.Total && info.Total > 0
|
||||
})
|
||||
.map((provider) => {
|
||||
const { Download = 0, Upload = 0, Total = 0 } = provider.subscriptionInfo!
|
||||
const used = Download + Upload
|
||||
const remaining = Math.max(0, Total - used)
|
||||
const percentage = Total > 0 ? toFinite(((used / Total) * 100).toFixed(2)) : 0
|
||||
|
||||
return {
|
||||
name: provider.name,
|
||||
used,
|
||||
remaining,
|
||||
total: Total,
|
||||
percentage,
|
||||
usedStr: prettyBytesHelper(used, { binary: true }),
|
||||
remainingStr: prettyBytesHelper(remaining, { binary: true }),
|
||||
totalStr: prettyBytesHelper(Total, { binary: true }),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const hasProvidersWithTraffic = computed(() => {
|
||||
return providersWithTraffic.value.length > 0
|
||||
})
|
||||
const hasMultipleProvidersWithTraffic = computed(() => {
|
||||
return providersWithTraffic.value.length > 1
|
||||
})
|
||||
|
||||
// 计算总流量
|
||||
const totalTraffic = computed(() => {
|
||||
const total = providersWithTraffic.value.reduce(
|
||||
(acc, provider) => ({
|
||||
used: acc.used + provider.used,
|
||||
remaining: acc.remaining + provider.remaining,
|
||||
total: acc.total + provider.total,
|
||||
}),
|
||||
{ used: 0, remaining: 0, total: 0 },
|
||||
)
|
||||
return total
|
||||
})
|
||||
|
||||
const totalPercentage = computed(() => {
|
||||
const { used, total } = totalTraffic.value
|
||||
return total > 0 ? toFinite(((used / total) * 100).toFixed(2)) : 0
|
||||
})
|
||||
|
||||
const totalUsedStr = computed(() => {
|
||||
return prettyBytesHelper(totalTraffic.value.used, { binary: true })
|
||||
})
|
||||
|
||||
const totalRemainingStr = computed(() => {
|
||||
return prettyBytesHelper(totalTraffic.value.remaining, { binary: true })
|
||||
})
|
||||
|
||||
const totalTotalStr = computed(() => {
|
||||
return prettyBytesHelper(totalTraffic.value.total, { binary: true })
|
||||
})
|
||||
|
||||
const getProgressColor = (percentage: number) => {
|
||||
if (percentage >= 90) return 'progress-error'
|
||||
if (percentage >= 70) return 'progress-warning'
|
||||
return 'progress-success'
|
||||
}
|
||||
</script>
|
||||
37
src/components/overview/RuleHitCountCard.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div
|
||||
class="card w-full"
|
||||
v-if="hasRulesWithExtra"
|
||||
>
|
||||
<div class="card-title px-4 pt-4">
|
||||
{{ $t('ruleHitCountCard') }}
|
||||
</div>
|
||||
<div class="card-body gap-4">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="pb-2 text-sm font-bold">
|
||||
{{ $t('ruleHitChart') }}
|
||||
</div>
|
||||
<RuleHitCountChart type="hit" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="pb-2 text-sm font-bold">
|
||||
{{ $t('ruleMissChart') }}
|
||||
</div>
|
||||
<RuleHitCountChart type="miss" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import RuleHitCountChart from '@/components/overview/RuleHitCountChart.vue'
|
||||
import { rules } from '@/store/rules'
|
||||
import { computed } from 'vue'
|
||||
|
||||
// 检查是否有规则含有 extra 字段
|
||||
const hasRulesWithExtra = computed(() => {
|
||||
return rules.value.some((rule) => rule.extra)
|
||||
})
|
||||
</script>
|
||||
282
src/components/overview/RuleHitCountChart.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<div class="relative h-48 w-full overflow-hidden xl:h-64">
|
||||
<div
|
||||
ref="chart"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
<span
|
||||
:class="
|
||||
type === 'hit'
|
||||
? 'border-b-primary/30 border-t-primary/60 border-l-primary/30 border-r-primary/60 text-base-content/10 bg-base-100/70 hidden'
|
||||
: 'border-b-info/30 border-t-info/60 border-l-info/30 border-r-info/60 text-base-content/10 bg-base-100/70 hidden'
|
||||
"
|
||||
ref="colorRef"
|
||||
/>
|
||||
<div
|
||||
v-if="barData.length === 0"
|
||||
class="text-base-content/50 absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div>{{ t('noData') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs absolute right-1 bottom-0"
|
||||
@click="isPaused = !isPaused"
|
||||
>
|
||||
<component
|
||||
:is="!isPaused ? PauseCircleIcon : PlayCircleIcon"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isMiddleScreen } from '@/helper/utils'
|
||||
import { rules } from '@/store/rules'
|
||||
import { font, theme } from '@/store/settings'
|
||||
import type { Rule } from '@/types'
|
||||
import { PauseCircleIcon, PlayCircleIcon } from '@heroicons/vue/24/outline'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { BarChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { debounce } from 'lodash'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
echarts.use([BarChart, GridComponent, TooltipComponent, CanvasRenderer])
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'hit' | 'miss'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const colorRef = ref()
|
||||
const chart = ref()
|
||||
const isPaused = ref(false)
|
||||
const colorSet = {
|
||||
primary30: '',
|
||||
primary60: '',
|
||||
info30: '',
|
||||
info60: '',
|
||||
baseContent10: '',
|
||||
baseContent: '',
|
||||
base70: '',
|
||||
}
|
||||
|
||||
let fontFamily = ''
|
||||
|
||||
const updateColorSet = () => {
|
||||
const colorStyle = getComputedStyle(colorRef.value)
|
||||
|
||||
colorSet.baseContent = colorStyle.getPropertyValue('--color-base-content').trim()
|
||||
colorSet.base70 = colorStyle.backgroundColor
|
||||
colorSet.baseContent10 = colorStyle.color
|
||||
if (props.type === 'hit') {
|
||||
colorSet.primary30 = colorStyle.borderTopColor
|
||||
colorSet.primary60 = colorStyle.borderBottomColor
|
||||
} else {
|
||||
colorSet.info30 = colorStyle.borderTopColor
|
||||
colorSet.info60 = colorStyle.borderBottomColor
|
||||
}
|
||||
}
|
||||
|
||||
const updateFontFamily = () => {
|
||||
const baseColorStyle = getComputedStyle(colorRef.value)
|
||||
fontFamily = baseColorStyle.fontFamily
|
||||
}
|
||||
|
||||
const barData = computed(() => {
|
||||
const maxItems = isMiddleScreen.value ? 8 : 20
|
||||
const getValue = (rule: Rule) => {
|
||||
return props.type === 'hit' ? rule.extra?.hitCount || 0 : rule.extra?.missCount || 0
|
||||
}
|
||||
const rulesWithCount = rules.value
|
||||
.filter((rule) => rule.extra)
|
||||
.sort((a, b) => getValue(b) - getValue(a))
|
||||
.slice(0, maxItems)
|
||||
.map((rule) => {
|
||||
const key = `${rule.type}\n${rule.payload}`
|
||||
const count = getValue(rule)
|
||||
return { name: key, value: count }
|
||||
})
|
||||
|
||||
return rulesWithCount
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
if (barData.value.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const categories = barData.value.map((item) => item.name)
|
||||
const values = barData.value.map((item) => item.value)
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
backgroundColor: colorSet.base70,
|
||||
borderColor: colorSet.base70,
|
||||
confine: true,
|
||||
padding: [8, 12],
|
||||
textStyle: {
|
||||
color: colorSet.baseContent,
|
||||
fontFamily,
|
||||
},
|
||||
formatter: (params: { name: string; value: number }) => {
|
||||
const param = Array.isArray(params) ? params[0] : params
|
||||
const translationKey = props.type === 'hit' ? 'ruleHitCount' : 'ruleMissCount'
|
||||
return `
|
||||
<div>
|
||||
<div class="font-semibold">${param.name}</div>
|
||||
<div class="text-sm opacity-80 mt-1">${t(translationKey, { count: param.value })}</div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: '2%',
|
||||
right: '2%',
|
||||
top: '5%',
|
||||
bottom: '20%',
|
||||
containLabel: false,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: categories,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: colorSet.baseContent,
|
||||
fontFamily,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
color: colorSet.baseContent10,
|
||||
},
|
||||
},
|
||||
axisLabel: {
|
||||
color: colorSet.baseContent,
|
||||
fontFamily,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('rule'),
|
||||
type: 'bar',
|
||||
data: values,
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: props.type === 'hit' ? colorSet.primary60 : colorSet.info60,
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: props.type === 'hit' ? colorSet.primary30 : colorSet.info30,
|
||||
},
|
||||
]),
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: (params: { value: number }) => {
|
||||
return params.value
|
||||
},
|
||||
color: colorSet.baseContent,
|
||||
fontFamily,
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: props.type === 'hit' ? colorSet.primary60 : colorSet.info60,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
let myChart: echarts.ECharts | null = null
|
||||
let touchEndHandler: ((e: TouchEvent) => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
updateColorSet()
|
||||
updateFontFamily()
|
||||
|
||||
watch(theme, updateColorSet)
|
||||
watch(font, updateFontFamily)
|
||||
watch(() => props.type, updateColorSet)
|
||||
|
||||
myChart = echarts.init(chart.value)
|
||||
|
||||
myChart.setOption(options.value)
|
||||
|
||||
watch(options, () => {
|
||||
if (isPaused.value) {
|
||||
return
|
||||
}
|
||||
myChart?.setOption(options.value)
|
||||
})
|
||||
|
||||
watch(barData, () => {
|
||||
if (isPaused.value) {
|
||||
return
|
||||
}
|
||||
if (myChart && barData.value.length > 0) {
|
||||
myChart.setOption(options.value)
|
||||
} else if (myChart && barData.value.length === 0) {
|
||||
myChart.clear()
|
||||
}
|
||||
})
|
||||
|
||||
watch(isMiddleScreen, () => {
|
||||
if (isPaused.value) {
|
||||
return
|
||||
}
|
||||
if (myChart && barData.value.length > 0) {
|
||||
myChart.setOption(options.value)
|
||||
}
|
||||
})
|
||||
|
||||
const { width } = useElementSize(chart)
|
||||
const resize = debounce(() => {
|
||||
myChart?.resize()
|
||||
}, 100)
|
||||
|
||||
watch(width, resize)
|
||||
|
||||
// 移动端:松手后自动隐藏 tooltip
|
||||
if (isMiddleScreen.value && chart.value) {
|
||||
touchEndHandler = () => {
|
||||
if (myChart) {
|
||||
myChart.dispatchAction({ type: 'hideTip' })
|
||||
}
|
||||
}
|
||||
chart.value.addEventListener('touchend', touchEndHandler)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart.value && touchEndHandler) {
|
||||
chart.value.removeEventListener('touchend', touchEndHandler)
|
||||
}
|
||||
if (myChart) {
|
||||
myChart.dispose()
|
||||
myChart = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
50
src/components/overview/SpeedCharts.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<BasicCharts
|
||||
ref="chartRef"
|
||||
:data="chartsData"
|
||||
:label-formatter="labelFormatter"
|
||||
:tool-tip-formatter="tooltipFormatter"
|
||||
:min="60 * 1000"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getToolTipForParams } from '@/helper'
|
||||
import { prettyBytesHelper } from '@/helper/utils'
|
||||
import { downloadSpeedHistory, uploadSpeedHistory } from '@/store/overview'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BasicCharts from './BasicCharts.vue'
|
||||
|
||||
const chartRef = ref()
|
||||
const { t } = useI18n()
|
||||
const chartsData = computed(() => {
|
||||
return [
|
||||
{
|
||||
name: t('ulSpeed'),
|
||||
data: uploadSpeedHistory.value,
|
||||
},
|
||||
{
|
||||
name: t('dlSpeed'),
|
||||
data: downloadSpeedHistory.value,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const labelFormatter = (value: number) => {
|
||||
return `${prettyBytesHelper(value, {
|
||||
maximumFractionDigits: 0,
|
||||
binary: false,
|
||||
})}/s`
|
||||
}
|
||||
const tooltipFormatter = (value: ToolTipParams[]) => {
|
||||
return value
|
||||
.map((item) => {
|
||||
return getToolTipForParams(item, {
|
||||
binary: false,
|
||||
suffix: '/s',
|
||||
})
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
</script>
|
||||
72
src/components/overview/StatisticsStats.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div :class="className.list">
|
||||
<div
|
||||
v-for="stat in order"
|
||||
:key="stat"
|
||||
:class="className.item"
|
||||
>
|
||||
<div :class="className.label">{{ $t(stat) }}</div>
|
||||
<div :class="className.value">{{ statisticsMap[stat] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { STATISTICS_TYPE, statisticsMap } from '@/composables/statistics'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'overview' | 'ctrl' | 'settings'
|
||||
}>()
|
||||
|
||||
const classMap = {
|
||||
overview: {
|
||||
list: 'grid grid-cols-2 gap-2 rounded-lg bg-base-200/50 px-4 py-2 lg:grid-cols-6',
|
||||
item: 'flex h-12 flex-col items-start justify-center lg:gap-2 lg:h-24 lg:items-center',
|
||||
label: 'text-sm text-base-content/70',
|
||||
value: 'text-lg lg:text-2xl font-bold',
|
||||
},
|
||||
settings: {
|
||||
list: 'grid w-full grid-cols-3 gap-1 rounded-lg bg-base-200/50 p-3',
|
||||
item: 'flex flex-col items-start',
|
||||
label: 'text-xs text-base-content/70',
|
||||
value: 'text-sm',
|
||||
},
|
||||
ctrl: {
|
||||
list: 'grid w-full grid-cols-2 gap-2 rounded-lg bg-base-200/50 p-2',
|
||||
item: 'flex items-start flex-col',
|
||||
label: 'text-xs text-base-content/70',
|
||||
value: 'text-sm',
|
||||
},
|
||||
}
|
||||
|
||||
const orderMap = {
|
||||
overview: [
|
||||
STATISTICS_TYPE.CONNECTIONS,
|
||||
STATISTICS_TYPE.MEMORY_USAGE,
|
||||
STATISTICS_TYPE.DOWNLOAD,
|
||||
STATISTICS_TYPE.DL_SPEED,
|
||||
STATISTICS_TYPE.UPLOAD,
|
||||
STATISTICS_TYPE.UL_SPEED,
|
||||
],
|
||||
settings: [
|
||||
STATISTICS_TYPE.CONNECTIONS,
|
||||
STATISTICS_TYPE.DOWNLOAD,
|
||||
STATISTICS_TYPE.DL_SPEED,
|
||||
STATISTICS_TYPE.MEMORY_USAGE,
|
||||
STATISTICS_TYPE.UPLOAD,
|
||||
STATISTICS_TYPE.UL_SPEED,
|
||||
],
|
||||
ctrl: [
|
||||
STATISTICS_TYPE.CONNECTIONS,
|
||||
STATISTICS_TYPE.MEMORY_USAGE,
|
||||
STATISTICS_TYPE.DOWNLOAD,
|
||||
STATISTICS_TYPE.DL_SPEED,
|
||||
STATISTICS_TYPE.UPLOAD,
|
||||
STATISTICS_TYPE.UL_SPEED,
|
||||
],
|
||||
}
|
||||
|
||||
const className = computed(() => classMap[props.type])
|
||||
const order = computed(() => orderMap[props.type])
|
||||
</script>
|
||||
465
src/components/overview/TopologyCharts.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="card-title absolute px-4 pt-4">
|
||||
{{ $t('connectionTopology') }}
|
||||
</div>
|
||||
<div
|
||||
:class="twMerge('relative h-96 w-full overflow-hidden pt-12')"
|
||||
@mousemove.stop
|
||||
@touchmove.stop
|
||||
>
|
||||
<div
|
||||
ref="chart"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
<span
|
||||
class="border-base-content/30 text-base-content/10 bg-base-100/70 hidden"
|
||||
ref="colorRef"
|
||||
/>
|
||||
<div
|
||||
v-if="sankeyData.nodes.length === 0"
|
||||
class="text-base-content/50 absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div>{{ t('noData') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute right-1 bottom-1 flex flex-col gap-1"
|
||||
:class="isFullScreen ? 'fixed right-4 bottom-4 mb-[env(safe-area-inset-bottom)]' : ''"
|
||||
>
|
||||
<button
|
||||
class="btn btn-ghost btn-circle btn-sm"
|
||||
@click="isPaused = !isPaused"
|
||||
>
|
||||
<component
|
||||
:is="!isPaused ? PauseCircleIcon : PlayCircleIcon"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-circle btn-sm"
|
||||
@click="isFullScreen = !isFullScreen"
|
||||
>
|
||||
<component
|
||||
:is="isFullScreen ? ArrowsPointingInIcon : ArrowsPointingOutIcon"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isFullScreen"
|
||||
class="bg-base-100 custom-background fixed inset-0 z-[9999] h-screen w-screen bg-cover bg-center"
|
||||
:class="`blur-intensity-${blurIntensity} custom-background-${dashboardTransparent}`"
|
||||
:style="backgroundImage"
|
||||
>
|
||||
<div
|
||||
ref="fullScreenChart"
|
||||
:class="shouldRotate ? 'bg-base-100' : 'bg-base-100 h-full w-full'"
|
||||
:style="fullChartStyle"
|
||||
/>
|
||||
<div class="fixed right-4 bottom-4 mb-[env(safe-area-inset-bottom)] flex flex-col gap-1">
|
||||
<button
|
||||
class="btn btn-ghost btn-circle btn-sm"
|
||||
@click="isPaused = !isPaused"
|
||||
>
|
||||
<component
|
||||
:is="!isPaused ? PauseCircleIcon : PlayCircleIcon"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-circle btn-sm"
|
||||
@click="isFullScreen = false"
|
||||
>
|
||||
<ArrowsPointingInIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { backgroundImage } from '@/helper/indexeddb'
|
||||
import { getIPLabelFromMap } from '@/helper/sourceip'
|
||||
import { isMiddleScreen } from '@/helper/utils'
|
||||
import { activeConnections } from '@/store/connections'
|
||||
import { blurIntensity, dashboardTransparent, font, theme } from '@/store/settings'
|
||||
import {
|
||||
ArrowsPointingInIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
PauseCircleIcon,
|
||||
PlayCircleIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core'
|
||||
import { SankeyChart } from 'echarts/charts'
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { debounce } from 'lodash'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
echarts.use([SankeyChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer])
|
||||
|
||||
const { t } = useI18n()
|
||||
const isFullScreen = ref(false)
|
||||
const isPaused = ref(false)
|
||||
const colorRef = ref()
|
||||
const chart = ref()
|
||||
const fullScreenChart = ref()
|
||||
const fullScreenMyChart = ref<echarts.ECharts>()
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize()
|
||||
|
||||
const shouldRotate = computed(() => {
|
||||
return isFullScreen.value && isMiddleScreen.value && windowHeight.value > windowWidth.value
|
||||
})
|
||||
|
||||
const fullChartStyle = computed(() => {
|
||||
const baseStyle = `backdrop-filter: blur(${blurIntensity.value}px);`
|
||||
|
||||
if (shouldRotate.value) {
|
||||
return `${baseStyle} transform: rotate(90deg); width: 100vh; height: 100vw; position: absolute; top: 50%; left: 50%; margin-top: -50vw; margin-left: -50vh;`
|
||||
}
|
||||
|
||||
return baseStyle
|
||||
})
|
||||
const colorSet = {
|
||||
baseContent10: '',
|
||||
baseContent30: '',
|
||||
baseContent: '',
|
||||
base70: '',
|
||||
}
|
||||
|
||||
let fontFamily = ''
|
||||
|
||||
const updateColorSet = () => {
|
||||
const colorStyle = getComputedStyle(colorRef.value)
|
||||
|
||||
colorSet.baseContent = colorStyle.getPropertyValue('--color-base-content').trim()
|
||||
colorSet.baseContent10 = colorStyle.color
|
||||
colorSet.baseContent30 = colorStyle.borderColor
|
||||
colorSet.base70 = colorStyle.backgroundColor
|
||||
}
|
||||
|
||||
const updateFontFamily = () => {
|
||||
const baseColorStyle = getComputedStyle(colorRef.value)
|
||||
fontFamily = baseColorStyle.fontFamily
|
||||
}
|
||||
|
||||
const sankeyData = computed(() => {
|
||||
const connections = activeConnections.value
|
||||
if (!connections || connections.length === 0) {
|
||||
return { nodes: [], links: [] }
|
||||
}
|
||||
|
||||
const nodeMap = new Map<string, number>()
|
||||
const linkMap = new Map<string, number>()
|
||||
const layerMap = new Map<string, number>()
|
||||
const nodeTypeMap = new Map<string, string>()
|
||||
let nodeIndex = 0
|
||||
|
||||
const addNode = (name: string, layer: number, type: string) => {
|
||||
if (!nodeMap.has(name)) {
|
||||
nodeMap.set(name, nodeIndex++)
|
||||
layerMap.set(name, layer)
|
||||
nodeTypeMap.set(name, type)
|
||||
}
|
||||
return nodeMap.get(name)!
|
||||
}
|
||||
|
||||
connections.forEach((conn) => {
|
||||
const sourceIP = getIPLabelFromMap(conn.metadata.sourceIP)
|
||||
const rulePayload = conn.rulePayload ? `${conn.rule}: ${conn.rulePayload}` : conn.rule
|
||||
const chains = conn.chains || []
|
||||
|
||||
if (chains.length === 0) return
|
||||
|
||||
const chainLast = chains[chains.length - 1]
|
||||
const chainFirst = chains[0]
|
||||
|
||||
const sourceNode = addNode(sourceIP, 0, t('sourceIPAddress'))
|
||||
const ruleNode = addNode(rulePayload, 1, t('ruleMatch'))
|
||||
|
||||
if (chainFirst === chainLast) {
|
||||
const chainExitNode = addNode(chainFirst, 3, t('proxyChainExit'))
|
||||
|
||||
const link1 = `${sourceNode}-${ruleNode}`
|
||||
const link2 = `${ruleNode}-${chainExitNode}`
|
||||
|
||||
linkMap.set(link1, (linkMap.get(link1) || 0) + 1)
|
||||
linkMap.set(link2, (linkMap.get(link2) || 0) + 1)
|
||||
} else {
|
||||
const chainLastNode = addNode(chainLast, 2, t('proxyChainEntry'))
|
||||
const chainFirstNode = addNode(chainFirst, 3, t('proxyChainExit'))
|
||||
|
||||
const link1 = `${sourceNode}-${ruleNode}`
|
||||
const link2 = `${ruleNode}-${chainLastNode}`
|
||||
const link3 = `${chainLastNode}-${chainFirstNode}`
|
||||
|
||||
linkMap.set(link1, (linkMap.get(link1) || 0) + 1)
|
||||
linkMap.set(link2, (linkMap.get(link2) || 0) + 1)
|
||||
linkMap.set(link3, (linkMap.get(link3) || 0) + 1)
|
||||
}
|
||||
})
|
||||
|
||||
// 创建初始节点数组
|
||||
const initialNodes = Array.from(nodeMap.entries()).map(([name, index]) => ({
|
||||
id: index,
|
||||
name: name,
|
||||
nodeType: nodeTypeMap.get(name) || t('unknown'),
|
||||
layer: layerMap.get(name) || 0,
|
||||
itemStyle: {
|
||||
color: layerColors[layerMap.get(name) || 0],
|
||||
},
|
||||
}))
|
||||
|
||||
// 按层分组节点
|
||||
const nodesByLayer = new Map<number, typeof initialNodes>()
|
||||
initialNodes.forEach((node) => {
|
||||
const layer = node.layer
|
||||
if (!nodesByLayer.has(layer)) {
|
||||
nodesByLayer.set(layer, [])
|
||||
}
|
||||
nodesByLayer.get(layer)!.push(node)
|
||||
})
|
||||
|
||||
// 对每一层的节点按名称进行字典排序
|
||||
const sortedLayers = Array.from(nodesByLayer.keys()).sort((a, b) => a - b)
|
||||
const idMapping = new Map<number, number>() // 旧 id -> 新 id 映射
|
||||
const sortedNodes: typeof initialNodes = []
|
||||
let newId = 0
|
||||
|
||||
sortedLayers.forEach((layer) => {
|
||||
const layerNodes = nodesByLayer.get(layer)!
|
||||
// 对当前层的节点按名称进行字典排序
|
||||
layerNodes.sort((a, b) => a.name.localeCompare(b.name))
|
||||
// 重新分配 id
|
||||
layerNodes.forEach((node) => {
|
||||
idMapping.set(node.id, newId)
|
||||
sortedNodes.push({
|
||||
...node,
|
||||
id: newId,
|
||||
})
|
||||
newId++
|
||||
})
|
||||
})
|
||||
|
||||
// 更新 links 中的 source 和 target 引用
|
||||
const links = Array.from(linkMap.entries()).map(([link, value]) => {
|
||||
const [oldSource, oldTarget] = link.split('-').map(Number)
|
||||
const source = idMapping.get(oldSource)!
|
||||
const target = idMapping.get(oldTarget)!
|
||||
// 使用对数缩放来压缩数据范围,使小值更明显
|
||||
// 公式: log10(value + 1) * 10,确保最小值为0,同时保持相对大小关系
|
||||
const scaledValue = Math.log10(value + 1) * 10
|
||||
return {
|
||||
source,
|
||||
target,
|
||||
value: scaledValue,
|
||||
originalValue: value, // 保存原始值用于 tooltip 显示
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes: sortedNodes, links }
|
||||
})
|
||||
|
||||
const layerColors = ['#6a6fc5', '#a8d4a0', '#fddb8a', '#f2a0a0']
|
||||
|
||||
const options = computed(() => ({
|
||||
backgroundColor: 'transparent',
|
||||
textStyle: {
|
||||
fontFamily: fontFamily || 'inherit',
|
||||
color: colorSet.baseContent,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
triggerOn: 'mousemove',
|
||||
backgroundColor: colorSet.base70,
|
||||
borderColor: colorSet.baseContent30,
|
||||
textStyle: {
|
||||
color: colorSet.baseContent,
|
||||
},
|
||||
formatter: (params: {
|
||||
dataType: string
|
||||
data: {
|
||||
name: string
|
||||
nodeType?: string
|
||||
source: number
|
||||
target: number
|
||||
value: number
|
||||
originalValue?: number
|
||||
}
|
||||
}) => {
|
||||
if (params.dataType === 'node') {
|
||||
return `${params.data.name}<br/>${t('nodeType')}: ${params.data.nodeType || t('unknown')}`
|
||||
} else if (params.dataType === 'edge') {
|
||||
const sourceNode = sankeyData.value.nodes.find((n) => n.id === params.data.source)
|
||||
const targetNode = sankeyData.value.nodes.find((n) => n.id === params.data.target)
|
||||
// 使用原始值显示真实的连接数量
|
||||
const displayValue = params.data.originalValue || params.data.value
|
||||
if (sourceNode && targetNode) {
|
||||
return `${sourceNode.name} → ${targetNode.name}<br/>${t('connectionCount')}: ${displayValue}`
|
||||
}
|
||||
return `${t('connectionCount')}: ${displayValue}`
|
||||
}
|
||||
return ''
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
id: 'sankey',
|
||||
type: 'sankey',
|
||||
layout: 'none',
|
||||
data: sankeyData.value.nodes,
|
||||
links: sankeyData.value.links,
|
||||
emphasis: {
|
||||
focus: 'trajectory',
|
||||
},
|
||||
lineStyle: {
|
||||
color: 'gradient',
|
||||
curveness: 0.5,
|
||||
},
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
label: {
|
||||
color: colorSet.baseContent,
|
||||
fontSize: isMiddleScreen.value ? 10 : 12,
|
||||
formatter: (params: { name: string }) => {
|
||||
const name = params.name
|
||||
const length = isFullScreen.value ? 45 : isMiddleScreen.value ? 20 : 30
|
||||
return name.length > length ? name.substring(0, length) + '...' : name
|
||||
},
|
||||
},
|
||||
nodeGap: 4,
|
||||
nodeWidth: 15,
|
||||
nodeAlign: 'left',
|
||||
animation: true,
|
||||
animationDuration: 1000,
|
||||
animationEasing: 'cubicOut',
|
||||
animationDelay: (idx: number) => idx * 50,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
updateColorSet()
|
||||
updateFontFamily()
|
||||
|
||||
watch(theme, updateColorSet)
|
||||
watch(font, updateFontFamily)
|
||||
|
||||
const myChart = echarts.init(chart.value)
|
||||
|
||||
myChart.setOption(options.value)
|
||||
|
||||
// 监听 tooltip 显示和隐藏事件
|
||||
myChart.on('showTip', () => {
|
||||
isPaused.value = true
|
||||
})
|
||||
myChart.on('hideTip', () => {
|
||||
isPaused.value = false
|
||||
})
|
||||
|
||||
const updateChartData = debounce((newData: typeof sankeyData.value) => {
|
||||
if (isPaused.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (myChart && newData.nodes.length > 0) {
|
||||
myChart.setOption(options.value)
|
||||
} else if (myChart && newData.nodes.length === 0) {
|
||||
myChart.clear()
|
||||
}
|
||||
|
||||
if (isFullScreen.value) {
|
||||
nextTick(() => {
|
||||
if (!fullScreenMyChart.value) {
|
||||
fullScreenMyChart.value = echarts.init(fullScreenChart.value)
|
||||
// 为全屏图表也添加事件监听
|
||||
fullScreenMyChart.value.on('showTip', () => {
|
||||
isPaused.value = true
|
||||
})
|
||||
fullScreenMyChart.value.on('hideTip', () => {
|
||||
isPaused.value = false
|
||||
})
|
||||
}
|
||||
if (fullScreenMyChart.value && newData.nodes.length > 0) {
|
||||
fullScreenMyChart.value.setOption(options.value)
|
||||
} else if (fullScreenMyChart.value && newData.nodes.length === 0) {
|
||||
fullScreenMyChart.value.clear()
|
||||
}
|
||||
})
|
||||
}
|
||||
}, 300)
|
||||
|
||||
watch(sankeyData, updateChartData, { deep: true })
|
||||
|
||||
watch([theme, font], () => {
|
||||
if (myChart) {
|
||||
myChart.setOption(options.value)
|
||||
}
|
||||
if (fullScreenMyChart.value) {
|
||||
fullScreenMyChart.value.setOption(options.value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(isFullScreen, () => {
|
||||
if (isFullScreen.value) {
|
||||
nextTick(() => {
|
||||
if (!fullScreenMyChart.value) {
|
||||
fullScreenMyChart.value = echarts.init(fullScreenChart.value)
|
||||
// 为全屏图表也添加事件监听
|
||||
fullScreenMyChart.value.on('showTip', () => {
|
||||
isPaused.value = true
|
||||
})
|
||||
fullScreenMyChart.value.on('hideTip', () => {
|
||||
isPaused.value = false
|
||||
})
|
||||
}
|
||||
if (fullScreenMyChart.value && sankeyData.value.nodes.length > 0) {
|
||||
fullScreenMyChart.value.setOption(options.value)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
fullScreenMyChart.value?.dispose()
|
||||
fullScreenMyChart.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const { width } = useElementSize(chart)
|
||||
const resize = debounce(() => {
|
||||
myChart.resize()
|
||||
fullScreenMyChart.value?.resize()
|
||||
}, 100)
|
||||
|
||||
watch(width, resize)
|
||||
|
||||
// 监听窗口大小变化和旋转状态变化,确保全屏图表正确调整大小
|
||||
watch([windowWidth, windowHeight, shouldRotate], () => {
|
||||
if (isFullScreen.value && fullScreenMyChart.value) {
|
||||
nextTick(() => {
|
||||
fullScreenMyChart.value?.resize()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart.value) {
|
||||
const myChart = echarts.getInstanceByDom(chart.value)
|
||||
if (myChart) {
|
||||
myChart.dispose()
|
||||
}
|
||||
}
|
||||
if (fullScreenMyChart.value) {
|
||||
fullScreenMyChart.value.dispose()
|
||||
fullScreenMyChart.value = undefined
|
||||
}
|
||||
})
|
||||
</script>
|
||||
103
src/components/proxies/LatencyTag.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
twMerge(
|
||||
'latency-tag bg-base-100 flex h-5 w-10 items-center justify-center rounded-xl text-xs select-none md:hover:shadow-sm',
|
||||
color,
|
||||
)
|
||||
"
|
||||
@mouseenter="handlerHistoryTip"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="loading loading-dots loading-xs text-base-content/80"
|
||||
></span>
|
||||
<BoltIcon
|
||||
v-else-if="latency === NOT_CONNECTED || !latency"
|
||||
class="text-base-content h-3 w-3"
|
||||
/>
|
||||
<div
|
||||
v-show="latency !== NOT_CONNECTED && !loading"
|
||||
ref="latencyRef"
|
||||
>
|
||||
{{ latency }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NOT_CONNECTED } from '@/constant'
|
||||
import { getColorForLatency } from '@/helper'
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import { getHistoryByName, getLatencyByName } from '@/store/proxies'
|
||||
import { BoltIcon } from '@heroicons/vue/24/outline'
|
||||
import { CountUp } from 'countup.js'
|
||||
import dayjs from 'dayjs'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const { showTip } = useTooltip()
|
||||
const handlerHistoryTip = (e: Event) => {
|
||||
const history = getHistoryByName(props.name ?? '', props.groupName)
|
||||
|
||||
if (!history.length) return
|
||||
|
||||
const historyList = document.createElement('div')
|
||||
|
||||
historyList.classList.add('flex', 'flex-col', 'gap-1')
|
||||
for (const item of history) {
|
||||
const itemDiv = document.createElement('div')
|
||||
const time = document.createElement('div')
|
||||
const latency = document.createElement('div')
|
||||
|
||||
time.textContent = dayjs(item.time).format('YYYY-MM-DD HH:mm:ss')
|
||||
latency.textContent = item.delay + 'ms'
|
||||
latency.className = getColorForLatency(item.delay)
|
||||
|
||||
itemDiv.classList.add('flex', 'items-center', 'gap-2')
|
||||
itemDiv.append(time, latency)
|
||||
historyList.append(itemDiv)
|
||||
}
|
||||
|
||||
showTip(e, historyList, {
|
||||
delay: [1000, 0],
|
||||
trigger: 'mouseenter',
|
||||
touch: false,
|
||||
})
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
name?: string
|
||||
loading?: boolean
|
||||
groupName?: string
|
||||
}>()
|
||||
const latencyRef = ref()
|
||||
const latency = computed(() => getLatencyByName(props.name ?? '', props.groupName))
|
||||
let countUp: CountUp | null = null
|
||||
|
||||
onMounted(() => {
|
||||
watch(latency, (value, OldValue) => {
|
||||
if (!countUp) {
|
||||
nextTick(() => {
|
||||
countUp = new CountUp(latencyRef.value, latency.value, {
|
||||
duration: 1,
|
||||
separator: '',
|
||||
enableScrollSpy: false,
|
||||
startVal: OldValue,
|
||||
})
|
||||
countUp?.update(value)
|
||||
})
|
||||
} else {
|
||||
countUp?.update(value)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
countUp = null
|
||||
})
|
||||
|
||||
const color = computed(() => {
|
||||
return getColorForLatency(latency.value)
|
||||
})
|
||||
</script>
|
||||
100
src/components/proxies/ProxiesByProvider.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { useCalculateMaxProxies } from '@/composables/proxiesScroll'
|
||||
import { handlerProxySelect, proxyMap, proxyProviederList } from '@/store/proxies'
|
||||
import { computed } from 'vue'
|
||||
import ProxyNodeCard from './ProxyNodeCard.vue'
|
||||
import ProxyNodeGrid from './ProxyNodeGrid.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
now: string
|
||||
renderProxies: string[]
|
||||
}>()
|
||||
|
||||
const groupedProxies = computed(() => {
|
||||
const groupdProixes: Record<string, string[]> = {}
|
||||
const providerKeys: string[] = []
|
||||
|
||||
for (const proxy of props.renderProxies) {
|
||||
const proxyNode = proxyMap.value[proxy]
|
||||
const providerName =
|
||||
proxyNode['provider-name'] ||
|
||||
(proxyProviederList.value.find((group) => group.proxies.find((node) => node.name === proxy))
|
||||
?.name ??
|
||||
'')
|
||||
|
||||
if (groupdProixes[providerName]) {
|
||||
groupdProixes[providerName].push(proxy)
|
||||
} else {
|
||||
if (providerName === '') {
|
||||
providerKeys.unshift('')
|
||||
} else {
|
||||
providerKeys.push(providerName)
|
||||
}
|
||||
|
||||
groupdProixes[providerName] = [proxy]
|
||||
}
|
||||
}
|
||||
|
||||
return providerKeys.map((providerName) => ({
|
||||
providerName,
|
||||
proxies: groupdProixes[providerName],
|
||||
}))
|
||||
})
|
||||
|
||||
const activeIndex = groupedProxies.value.reduce((acc, { proxies }) => {
|
||||
const index = proxies.indexOf(props.now)
|
||||
|
||||
if (index !== -1) {
|
||||
return acc + index
|
||||
}
|
||||
return acc + proxies.length
|
||||
}, 0)
|
||||
|
||||
const { maxProxies } = useCalculateMaxProxies(props.renderProxies.length, activeIndex)
|
||||
|
||||
const truncatedProxies = computed(() => {
|
||||
let displayCount = 0
|
||||
const truncatedProxies: { providerName: string; proxies: string[] }[] = []
|
||||
|
||||
for (const { providerName, proxies } of groupedProxies.value) {
|
||||
if (displayCount + proxies.length > maxProxies.value) {
|
||||
truncatedProxies.push({
|
||||
providerName,
|
||||
proxies: proxies.slice(0, maxProxies.value - displayCount),
|
||||
})
|
||||
break
|
||||
} else {
|
||||
truncatedProxies.push({ providerName, proxies })
|
||||
displayCount += proxies.length
|
||||
}
|
||||
}
|
||||
return truncatedProxies
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="({ providerName, proxies }, index) in truncatedProxies"
|
||||
:key="index"
|
||||
>
|
||||
<p
|
||||
class="my-2 text-sm font-semibold"
|
||||
v-if="providerName !== ''"
|
||||
>
|
||||
{{ providerName }}
|
||||
</p>
|
||||
<ProxyNodeGrid>
|
||||
<ProxyNodeCard
|
||||
v-for="node in proxies"
|
||||
:key="node"
|
||||
:name="node"
|
||||
:group-name="name"
|
||||
:active="node === now"
|
||||
@click.stop="handlerProxySelect(name, node)"
|
||||
/>
|
||||
</ProxyNodeGrid>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
32
src/components/proxies/ProxiesContent.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { useCalculateMaxProxies } from '@/composables/proxiesScroll'
|
||||
import { handlerProxySelect } from '@/store/proxies'
|
||||
import { computed } from 'vue'
|
||||
import ProxyNodeCard from './ProxyNodeCard.vue'
|
||||
import ProxyNodeGrid from './ProxyNodeGrid.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
now?: string
|
||||
renderProxies: string[]
|
||||
}>()
|
||||
|
||||
const { maxProxies } = useCalculateMaxProxies(
|
||||
props.renderProxies.length,
|
||||
props.renderProxies.indexOf(props.now ?? ''),
|
||||
)
|
||||
const proxies = computed(() => props.renderProxies.slice(0, maxProxies.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProxyNodeGrid>
|
||||
<ProxyNodeCard
|
||||
v-for="node in proxies"
|
||||
:key="node"
|
||||
:name="node"
|
||||
:group-name="name"
|
||||
:active="node === now"
|
||||
@click.stop="handlerProxySelect(name, node)"
|
||||
/>
|
||||
</ProxyNodeGrid>
|
||||
</template>
|
||||
135
src/components/proxies/ProxyGroup.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<CollapseCard
|
||||
:name="proxyGroup.name"
|
||||
@contextmenu.prevent.stop="handlerLatencyTest"
|
||||
>
|
||||
<template v-slot:title>
|
||||
<div class="relative flex items-center gap-2">
|
||||
<div class="flex flex-1 items-center gap-1">
|
||||
<ProxyName
|
||||
:name="name"
|
||||
:icon-size="proxyGroupIconSize"
|
||||
:icon-margin="proxyGroupIconMargin"
|
||||
/>
|
||||
<span class="text-base-content/60 text-xs">
|
||||
: {{ proxyGroup.type }} ({{ proxiesCount }})
|
||||
</span>
|
||||
<button
|
||||
v-if="manageHiddenGroup"
|
||||
class="btn btn-circle btn-xs z-10 ml-1"
|
||||
@click.stop="handlerGroupToggle"
|
||||
>
|
||||
<EyeIcon
|
||||
v-if="!hiddenGroup"
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
<EyeSlashIcon
|
||||
v-else
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<LatencyTag
|
||||
:class="twMerge('bg-base-200/50 hover:bg-base-200 z-10')"
|
||||
:loading="isLatencyTesting"
|
||||
:name="proxyGroup.now"
|
||||
:group-name="proxyGroup.name"
|
||||
@click.stop="handlerLatencyTest"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-base-content/80 mt-1.5 flex items-center gap-2">
|
||||
<div class="flex flex-1 items-center gap-1 truncate text-sm">
|
||||
<ProxyGroupNow :name="name" />
|
||||
</div>
|
||||
<div class="min-w-12 shrink-0 text-right text-xs">
|
||||
{{ prettyBytesHelper(downloadTotal) }}/s
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:preview>
|
||||
<ProxyPreview
|
||||
:nodes="renderProxies"
|
||||
:now="proxyGroup.now"
|
||||
:groupName="proxyGroup.name"
|
||||
@nodeclick="handlerProxySelect(name, $event)"
|
||||
/>
|
||||
</template>
|
||||
<template v-slot:content>
|
||||
<Component
|
||||
:is="groupProxiesByProvider ? ProxiesByProvider : ProxiesContent"
|
||||
:name="name"
|
||||
:now="proxyGroup.now"
|
||||
:render-proxies="renderProxies"
|
||||
/>
|
||||
</template>
|
||||
</CollapseCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBounceOnVisible } from '@/composables/bouncein'
|
||||
import { useRenderProxies } from '@/composables/renderProxies'
|
||||
import { isHiddenGroup } from '@/helper'
|
||||
import { prettyBytesHelper } from '@/helper/utils'
|
||||
import { activeConnections } from '@/store/connections'
|
||||
import {
|
||||
handlerProxySelect,
|
||||
hiddenGroupMap,
|
||||
proxyGroupLatencyTest,
|
||||
proxyMap,
|
||||
} from '@/store/proxies'
|
||||
import {
|
||||
groupProxiesByProvider,
|
||||
manageHiddenGroup,
|
||||
proxyGroupIconMargin,
|
||||
proxyGroupIconSize,
|
||||
} from '@/store/settings'
|
||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/outline'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { computed, ref } from 'vue'
|
||||
import CollapseCard from '../common/CollapseCard.vue'
|
||||
import LatencyTag from './LatencyTag.vue'
|
||||
import ProxiesByProvider from './ProxiesByProvider.vue'
|
||||
import ProxiesContent from './ProxiesContent.vue'
|
||||
import ProxyGroupNow from './ProxyGroupNow.vue'
|
||||
import ProxyName from './ProxyName.vue'
|
||||
import ProxyPreview from './ProxyPreview.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
}>()
|
||||
const proxyGroup = computed(() => proxyMap.value[props.name])
|
||||
const allProxies = computed(() => proxyGroup.value.all ?? [])
|
||||
const { proxiesCount, renderProxies } = useRenderProxies(allProxies, props.name)
|
||||
const isLatencyTesting = ref(false)
|
||||
const handlerLatencyTest = async () => {
|
||||
if (isLatencyTesting.value) return
|
||||
|
||||
isLatencyTesting.value = true
|
||||
try {
|
||||
await proxyGroupLatencyTest(props.name)
|
||||
isLatencyTesting.value = false
|
||||
} catch {
|
||||
isLatencyTesting.value = false
|
||||
}
|
||||
}
|
||||
const downloadTotal = computed(() => {
|
||||
const speed = activeConnections.value
|
||||
.filter((conn) => conn.chains.includes(props.name))
|
||||
.reduce((total, conn) => total + conn.downloadSpeed, 0)
|
||||
|
||||
return speed
|
||||
})
|
||||
|
||||
const hiddenGroup = computed({
|
||||
get: () => isHiddenGroup(props.name),
|
||||
set: (value: boolean) => {
|
||||
hiddenGroupMap.value[props.name] = value
|
||||
},
|
||||
})
|
||||
|
||||
const handlerGroupToggle = () => {
|
||||
hiddenGroup.value = !hiddenGroup.value
|
||||
}
|
||||
|
||||
useBounceOnVisible()
|
||||
</script>
|
||||
257
src/components/proxies/ProxyGroupForMobile.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative h-22 cursor-pointer"
|
||||
ref="cardWrapperRef"
|
||||
@click="handlerGroupClick"
|
||||
>
|
||||
<div
|
||||
v-if="modalMode"
|
||||
class="bg-base-300/50 fixed inset-0 z-40 overflow-hidden"
|
||||
/>
|
||||
<div
|
||||
class="card absolute overflow-hidden transition-[width,transform,max-height] duration-200 ease-out will-change-transform"
|
||||
:class="modalMode && blurIntensity < 5 && 'backdrop-blur-sm!'"
|
||||
:style="cardStyle"
|
||||
@contextmenu.prevent.stop="handlerLatencyTest"
|
||||
@transitionend="handlerTransitionEnd"
|
||||
ref="cardRef"
|
||||
>
|
||||
<div class="relative flex h-22 shrink-0 flex-col justify-between p-2">
|
||||
<div
|
||||
class="text-md truncate"
|
||||
:class="proxyGroup.icon && 'pr-10'"
|
||||
>
|
||||
{{ proxyGroup.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-base-content/60 truncate text-xs"
|
||||
:class="proxyGroup.icon && 'pr-10'"
|
||||
>
|
||||
{{ proxyGroup.type }} ({{ proxiesCount }})
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-1 items-center gap-1 truncate">
|
||||
<button
|
||||
v-if="manageHiddenGroup"
|
||||
class="btn btn-circle btn-xs z-10"
|
||||
@click.stop="handlerGroupToggle"
|
||||
>
|
||||
<EyeIcon
|
||||
v-if="!hiddenGroup"
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
<EyeSlashIcon
|
||||
v-else
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
</button>
|
||||
<ProxyGroupNow
|
||||
:name="proxyGroup.name"
|
||||
:mobile="true"
|
||||
/>
|
||||
</div>
|
||||
<LatencyTag
|
||||
:class="twMerge('bg-base-200/50 hover:bg-base-200 z-10')"
|
||||
:loading="isLatencyTesting"
|
||||
:name="proxyGroup.now"
|
||||
:group-name="proxyGroup.name"
|
||||
@click.stop="handlerLatencyTest"
|
||||
/>
|
||||
</div>
|
||||
<ProxyIcon
|
||||
v-if="proxyGroup?.icon"
|
||||
:icon="proxyGroup.icon"
|
||||
:size="40"
|
||||
:margin="0"
|
||||
class="absolute top-2 right-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="displayContent"
|
||||
class="will-change-opacity max-h-108 overflow-y-auto overscroll-contain p-2 transition-opacity duration-200 ease-out"
|
||||
:class="[SCROLLABLE_PARENT_CLASS]"
|
||||
:style="{
|
||||
width: 'calc(100vw - 1rem)',
|
||||
opacity: contentOpacity,
|
||||
contain: 'layout style paint',
|
||||
}"
|
||||
>
|
||||
<Component
|
||||
:is="groupProxiesByProvider ? ProxiesByProvider : ProxiesContent"
|
||||
:name="name"
|
||||
:now="proxyGroup.now"
|
||||
:render-proxies="renderProxies"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBounceOnVisible } from '@/composables/bouncein'
|
||||
import { disableProxiesPageScroll } from '@/composables/proxies'
|
||||
import { useRenderProxies } from '@/composables/renderProxies'
|
||||
import { isHiddenGroup } from '@/helper'
|
||||
import { SCROLLABLE_PARENT_CLASS } from '@/helper/utils'
|
||||
import { hiddenGroupMap, proxyGroupLatencyTest, proxyMap } from '@/store/proxies'
|
||||
import { blurIntensity, groupProxiesByProvider, manageHiddenGroup } from '@/store/settings'
|
||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/outline'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import LatencyTag from './LatencyTag.vue'
|
||||
import ProxiesByProvider from './ProxiesByProvider.vue'
|
||||
import ProxiesContent from './ProxiesContent.vue'
|
||||
import ProxyGroupNow from './ProxyGroupNow.vue'
|
||||
import ProxyIcon from './ProxyIcon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
}>()
|
||||
const proxyGroup = computed(() => proxyMap.value[props.name])
|
||||
const allProxies = computed(() => proxyGroup.value.all ?? [])
|
||||
const { proxiesCount, renderProxies } = useRenderProxies(allProxies, props.name)
|
||||
const isLatencyTesting = ref(false)
|
||||
|
||||
const modalMode = ref(false)
|
||||
const displayContent = ref(false)
|
||||
const showAllContent = ref(modalMode.value)
|
||||
const contentOpacity = ref(0)
|
||||
|
||||
const cardWrapperRef = ref()
|
||||
const cardRef = ref()
|
||||
|
||||
const INIT_STYLE = {
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 1,
|
||||
transform: 'translate3d(0, 0, 0) scale(1)',
|
||||
}
|
||||
const cardStyle = ref<Record<string, string | number>>({
|
||||
...INIT_STYLE,
|
||||
})
|
||||
|
||||
const calcCardStyle = () => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!cardWrapperRef.value) return
|
||||
|
||||
if (!modalMode.value) {
|
||||
cardStyle.value = {
|
||||
...cardStyle.value,
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
transform: 'translate3d(0, 0, 0) scale(1)',
|
||||
zIndex: 50,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const manyProxies = renderProxies.value.length > 4
|
||||
const { left, top, bottom } = cardWrapperRef.value.getBoundingClientRect()
|
||||
const { innerHeight, innerWidth } = window
|
||||
|
||||
const minSafeArea = innerHeight * 0.15
|
||||
const baseLine = innerHeight * 0.2
|
||||
const maxSafeArea = innerHeight * 0.3
|
||||
|
||||
const isLeft = left < innerWidth / 3
|
||||
const isTop = (top + bottom) * 0.5 < innerHeight * (manyProxies ? 0.7 : 0.5)
|
||||
const transformOrigin = isLeft
|
||||
? isTop
|
||||
? 'top left'
|
||||
: 'bottom left'
|
||||
: isTop
|
||||
? 'top right'
|
||||
: 'bottom right'
|
||||
const positionKeyX = isLeft ? 'left' : 'right'
|
||||
const positionKeyY = isTop ? 'top' : 'bottom'
|
||||
|
||||
let transformValueY = 0
|
||||
let verticalOffset = 0
|
||||
|
||||
if (isTop) {
|
||||
if (top < minSafeArea || (top > maxSafeArea && manyProxies)) {
|
||||
transformValueY = baseLine - top
|
||||
}
|
||||
verticalOffset = top + transformValueY
|
||||
} else {
|
||||
const minSafeBottom = innerHeight - minSafeArea
|
||||
const maxSafeBottom = innerHeight - maxSafeArea
|
||||
const baseLineBottom = innerHeight - baseLine
|
||||
|
||||
if (bottom > minSafeBottom || (bottom < maxSafeBottom && manyProxies)) {
|
||||
transformValueY = baseLineBottom - bottom
|
||||
}
|
||||
verticalOffset = innerHeight - bottom - transformValueY
|
||||
}
|
||||
|
||||
cardStyle.value = {
|
||||
width: 'calc(100vw - 1rem)',
|
||||
maxHeight: `${innerHeight - verticalOffset - 112}px`,
|
||||
transform: `translate3d(0, ${transformValueY}px, 0) scale(1)`,
|
||||
transformOrigin,
|
||||
zIndex: 50,
|
||||
[positionKeyY]: 0,
|
||||
[positionKeyX]: 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handlerTransitionEnd = (e: TransitionEvent) => {
|
||||
if (e.propertyName !== 'width') return
|
||||
|
||||
if (modalMode.value) {
|
||||
contentOpacity.value = 1
|
||||
showAllContent.value = true
|
||||
} else {
|
||||
displayContent.value = false
|
||||
|
||||
nextTick(() => {
|
||||
cardStyle.value = {
|
||||
...INIT_STYLE,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handlerGroupClick = async () => {
|
||||
modalMode.value = !modalMode.value
|
||||
disableProxiesPageScroll.value = modalMode.value
|
||||
|
||||
if (modalMode.value) {
|
||||
displayContent.value = true
|
||||
}
|
||||
showAllContent.value = false
|
||||
contentOpacity.value = 0
|
||||
|
||||
calcCardStyle()
|
||||
}
|
||||
|
||||
const handlerLatencyTest = async () => {
|
||||
if (isLatencyTesting.value) return
|
||||
|
||||
isLatencyTesting.value = true
|
||||
try {
|
||||
await proxyGroupLatencyTest(props.name)
|
||||
isLatencyTesting.value = false
|
||||
} catch {
|
||||
isLatencyTesting.value = false
|
||||
}
|
||||
}
|
||||
const hiddenGroup = computed({
|
||||
get: () => isHiddenGroup(props.name),
|
||||
set: (value: boolean) => {
|
||||
hiddenGroupMap.value[props.name] = value
|
||||
},
|
||||
})
|
||||
|
||||
const handlerGroupToggle = () => {
|
||||
hiddenGroup.value = !hiddenGroup.value
|
||||
}
|
||||
|
||||
useBounceOnVisible(cardRef)
|
||||
</script>
|
||||
70
src/components/proxies/ProxyGroupNow.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<template v-if="proxyGroup.now">
|
||||
<Component
|
||||
class="h-4 w-4 shrink-0 outline-none"
|
||||
:is="isFixed ? LockClosedIcon : ArrowRightCircleIcon"
|
||||
@mouseenter="tipForFixed"
|
||||
/>
|
||||
|
||||
<ProxyName
|
||||
:name="proxyGroup.now"
|
||||
class="text-base-content/80 text-xs md:text-sm"
|
||||
/>
|
||||
<template v-if="finalOutbound && displayFinalOutbound">
|
||||
<ArrowRightCircleIcon class="h-4 w-4 shrink-0" />
|
||||
<ProxyName
|
||||
:name="finalOutbound"
|
||||
class="text-base-content/80 text-xs md:text-sm"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="proxyGroup.type.toLowerCase() === PROXY_TYPE.LoadBalance">
|
||||
<CheckCircleIcon class="h-4 w-4 shrink-0" />
|
||||
<span class="text-base-content/80 text-xs md:text-sm">
|
||||
{{ $t('loadBalance') }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PROXY_TYPE } from '@/constant'
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import { getNowProxyNodeName, proxyMap } from '@/store/proxies'
|
||||
import { displayFinalOutbound } from '@/store/settings'
|
||||
import { ArrowRightCircleIcon, CheckCircleIcon, LockClosedIcon } from '@heroicons/vue/24/outline'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ProxyName from './ProxyName.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
mobile?: boolean
|
||||
}>()
|
||||
const proxyGroup = computed(() => proxyMap.value[props.name])
|
||||
const { showTip } = useTooltip()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isFixed = computed(() => {
|
||||
return proxyGroup.value.fixed === proxyGroup.value.now
|
||||
})
|
||||
|
||||
const tipForFixed = (e: Event) => {
|
||||
if (!isFixed.value) {
|
||||
return
|
||||
}
|
||||
|
||||
showTip(e, t('tipForFixed', { type: proxyGroup.value.type }), {
|
||||
delay: [500, 0],
|
||||
})
|
||||
}
|
||||
|
||||
const finalOutbound = computed(() => {
|
||||
const now = getNowProxyNodeName(proxyGroup.value.now)
|
||||
|
||||
if (now === proxyGroup.value.now) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return now
|
||||
})
|
||||
</script>
|
||||
49
src/components/proxies/ProxyIcon.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isDom"
|
||||
:class="['inline-block', fill || 'fill-primary']"
|
||||
:style="style"
|
||||
v-html="pureDom"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="inline-block"
|
||||
:style="style"
|
||||
:src="icon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DOMPurify from 'dompurify'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
icon: string
|
||||
fill?: string
|
||||
size?: number
|
||||
margin?: number
|
||||
}>(),
|
||||
{
|
||||
size: 16,
|
||||
margin: 4,
|
||||
},
|
||||
)
|
||||
|
||||
const style = computed(() => {
|
||||
return {
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
marginRight: `${props.margin}px`,
|
||||
}
|
||||
})
|
||||
const DOM_STARTS_WITH = 'data:image/svg+xml,'
|
||||
const isDom = computed(() => {
|
||||
return props.icon.startsWith(DOM_STARTS_WITH)
|
||||
})
|
||||
|
||||
const pureDom = computed(() => {
|
||||
if (!isDom.value) return
|
||||
return DOMPurify.sanitize(props.icon.replace(DOM_STARTS_WITH, ''))
|
||||
})
|
||||
</script>
|
||||
38
src/components/proxies/ProxyName.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<ProxyIcon
|
||||
v-if="icon"
|
||||
:icon="icon"
|
||||
:margin="iconMargin"
|
||||
:size="iconSize"
|
||||
/>
|
||||
{{ name }}
|
||||
<template v-if="dialerProxy"> ({{ dialerProxy }}) </template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { proxyMap } from '@/store/proxies'
|
||||
import { computed } from 'vue'
|
||||
import ProxyIcon from './ProxyIcon.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: string
|
||||
iconSize?: number
|
||||
iconMargin?: number
|
||||
}>(),
|
||||
{
|
||||
iconSize: 16,
|
||||
iconMargin: 4,
|
||||
},
|
||||
)
|
||||
|
||||
const node = computed(() => proxyMap.value[props.name])
|
||||
const icon = computed(() => {
|
||||
return node.value?.icon
|
||||
})
|
||||
const dialerProxy = computed(() => {
|
||||
return node.value?.['dialer-proxy']
|
||||
})
|
||||
</script>
|
||||
134
src/components/proxies/ProxyNodeCard.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div
|
||||
ref="cardRef"
|
||||
:class="
|
||||
twMerge(
|
||||
'bg-base-200 flex cursor-pointer flex-col items-start rounded-md',
|
||||
active ? 'bg-primary sm:hover:bg-primary/95' : 'sm:hover:bg-base-300',
|
||||
isSmallCard ? 'gap-1 p-1' : 'gap-2 p-2',
|
||||
latencyTipAnimationClass,
|
||||
)
|
||||
"
|
||||
@contextmenu.stop.prevent="handlerLatencyTest"
|
||||
>
|
||||
<div
|
||||
class="w-full flex-1 text-sm"
|
||||
:class="truncateProxyName && 'truncate'"
|
||||
@mouseenter="checkTruncation"
|
||||
>
|
||||
<ProxyIcon
|
||||
v-if="node?.icon"
|
||||
class="-mt-[2px] shrink-0 align-middle"
|
||||
:icon="node.icon"
|
||||
:fill="active ? 'fill-primary-content' : 'fill-base-content'"
|
||||
/><span
|
||||
v-if="active"
|
||||
class="text-primary-content"
|
||||
>{{ node.name }}</span
|
||||
><span
|
||||
v-else
|
||||
class="text-base-content"
|
||||
>{{ node.name }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex h-4 w-full items-center justify-between">
|
||||
<span
|
||||
:class="`truncate text-xs tracking-tight ${active ? 'text-primary-content' : 'text-base-content/60'}`"
|
||||
@mouseenter="checkTruncation"
|
||||
>
|
||||
{{ typeDescription }}
|
||||
</span>
|
||||
<LatencyTag
|
||||
:class="[isSmallCard && 'h-4! w-8! rounded-md!', 'shrink-0', active && 'hover:bg-base-300']"
|
||||
:name="node.name"
|
||||
:loading="isLatencyTesting"
|
||||
:group-name="groupName"
|
||||
@click.stop="handlerLatencyTest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PROXY_CARD_SIZE, PROXY_SORT_TYPE } from '@/constant'
|
||||
import { checkTruncation } from '@/helper/tooltip'
|
||||
import { scrollIntoCenter } from '@/helper/utils'
|
||||
import { getIPv6ByName, getTestUrl, proxyLatencyTest, proxyMap } from '@/store/proxies'
|
||||
import { IPv6test, proxyCardSize, proxySortType, truncateProxyName } from '@/store/settings'
|
||||
import { smartWeightsMap } from '@/store/smart'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import LatencyTag from './LatencyTag.vue'
|
||||
import ProxyIcon from './ProxyIcon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
active?: boolean
|
||||
groupName?: string
|
||||
}>()
|
||||
|
||||
const cardRef = ref()
|
||||
const node = computed(() => proxyMap.value[props.name])
|
||||
const isLatencyTesting = ref(false)
|
||||
const typeFormatter = (type: string) => {
|
||||
type = type.toLowerCase()
|
||||
type = type.replace('shadowsocks', 'ss')
|
||||
type = type.replace('hysteria', 'hy')
|
||||
type = type.replace('wireguard', 'wg')
|
||||
|
||||
return type
|
||||
}
|
||||
const isSmallCard = computed(() => proxyCardSize.value === PROXY_CARD_SIZE.SMALL)
|
||||
const typeDescription = computed(() => {
|
||||
const type = typeFormatter(node.value.type)
|
||||
const smartUsage = smartWeightsMap.value[props.groupName ?? '']?.[props.name]
|
||||
const smartDesc = smartUsage ? t(smartUsage) : ''
|
||||
const isV6 = IPv6test.value && getIPv6ByName(node.value.name) ? 'IPv6' : ''
|
||||
const isUDP = node.value.udp ? (node.value.xudp ? 'xudp' : 'udp') : ''
|
||||
|
||||
return [type, isUDP, smartDesc, isV6].filter(Boolean).join(isSmallCard.value ? '/' : ' / ')
|
||||
})
|
||||
|
||||
const latencyTipAnimationClass = ref<string[]>([])
|
||||
const handlerLatencyTest = async () => {
|
||||
if (isLatencyTesting.value) return
|
||||
|
||||
isLatencyTesting.value = true
|
||||
try {
|
||||
await proxyLatencyTest(props.name, getTestUrl(props.groupName))
|
||||
isLatencyTesting.value = false
|
||||
} catch {
|
||||
isLatencyTesting.value = false
|
||||
}
|
||||
|
||||
if (
|
||||
[PROXY_SORT_TYPE.LATENCY_ASC, PROXY_SORT_TYPE.LATENCY_DESC].includes(proxySortType.value) &&
|
||||
cardRef.value
|
||||
) {
|
||||
const classList = ['bg-info/20!', 'transition-colors', 'duration-1500']
|
||||
|
||||
scrollIntoCenter(cardRef.value)
|
||||
latencyTipAnimationClass.value = classList
|
||||
setTimeout(() => {
|
||||
latencyTipAnimationClass.value = []
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.active) {
|
||||
setTimeout(() => {
|
||||
scrollIntoCenter(cardRef.value)
|
||||
}, 300)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tooltip:before {
|
||||
z-index: 20;
|
||||
}
|
||||
</style>
|
||||
12
src/components/proxies/ProxyNodeGrid.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid gap-2"
|
||||
:style="`grid-template-columns: repeat(auto-fill, minmax(${minProxyCardWidth}px, 1fr));`"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { minProxyCardWidth } from '@/store/settings'
|
||||
</script>
|
||||
147
src/components/proxies/ProxyPreview.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div
|
||||
ref="previewRef"
|
||||
class="flex flex-wrap"
|
||||
:class="[showDots ? 'gap-1 pt-3' : 'gap-2 pt-4 pb-1']"
|
||||
>
|
||||
<template v-if="showDots">
|
||||
<div
|
||||
v-for="node in nodesLatency"
|
||||
:key="node.name"
|
||||
class="flex h-4 w-4 items-center justify-center rounded-full transition hover:scale-110"
|
||||
:class="getBgColor(node.latency)"
|
||||
ref="dotsRef"
|
||||
@mouseenter="(e) => makeTippy(e, node)"
|
||||
@click.stop="$emit('nodeclick', node.name)"
|
||||
>
|
||||
<div
|
||||
class="h-2 w-2 rounded-full bg-white"
|
||||
v-if="now === node.name"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-1 items-center justify-center overflow-hidden rounded-2xl *:h-2"
|
||||
>
|
||||
<div
|
||||
:class="getBgColor(lowLatency - 1)"
|
||||
:style="{
|
||||
width: `${(goodsCounts * 100) / nodes.length}%`, // cant use tw class, otherwise dynamic classname won't be generated
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
:class="getBgColor(mediumLatency - 1)"
|
||||
:style="{
|
||||
width: `${(mediumCounts * 100) / nodes.length}%`,
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
:class="getBgColor(mediumLatency + 1)"
|
||||
:style="{
|
||||
width: `${(badCounts * 100) / nodes.length}%`,
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
:class="getBgColor(NOT_CONNECTED)"
|
||||
:style="{
|
||||
width: `${(notConnectedCounts * 100) / nodes.length}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { NOT_CONNECTED, PROXY_PREVIEW_TYPE } from '@/constant'
|
||||
import { getColorForLatency } from '@/helper'
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import { getLatencyByName } from '@/store/proxies'
|
||||
import { lowLatency, mediumLatency, proxyPreviewType } from '@/store/settings'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: string[]
|
||||
now?: string
|
||||
groupName?: string
|
||||
}>()
|
||||
|
||||
const { showTip } = useTooltip()
|
||||
const previewRef = ref<HTMLElement | null>(null)
|
||||
const { width } = useElementSize(previewRef)
|
||||
|
||||
const widthEnough = computed(() => {
|
||||
return width.value > 20 * props.nodes.length
|
||||
})
|
||||
|
||||
const makeTippy = (e: Event, node: { name: string; latency: number }) => {
|
||||
const tag = document.createElement('div')
|
||||
const name = document.createElement('div')
|
||||
|
||||
name.textContent = node.name
|
||||
tag.append(name)
|
||||
|
||||
if (node.latency !== NOT_CONNECTED) {
|
||||
const latency = document.createElement('div')
|
||||
|
||||
latency.textContent = `${node.latency}ms`
|
||||
latency.classList.add(getColorForLatency(node.latency))
|
||||
tag.append(latency)
|
||||
}
|
||||
|
||||
tag.classList.add('flex', 'items-center', 'gap-2')
|
||||
showTip(e, tag)
|
||||
}
|
||||
|
||||
const showDots = computed(() => {
|
||||
return (
|
||||
proxyPreviewType.value === PROXY_PREVIEW_TYPE.DOTS ||
|
||||
(proxyPreviewType.value === PROXY_PREVIEW_TYPE.AUTO && widthEnough.value)
|
||||
)
|
||||
})
|
||||
|
||||
const nodesLatency = computed(() =>
|
||||
props.nodes.map((name) => {
|
||||
return {
|
||||
latency: getLatencyByName(name, props.groupName),
|
||||
name: name,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const getBgColor = (latency: number) => {
|
||||
if (latency === NOT_CONNECTED) {
|
||||
return 'bg-base-content/60'
|
||||
} else if (latency < lowLatency.value) {
|
||||
return 'bg-low-latency'
|
||||
} else if (latency < mediumLatency.value) {
|
||||
return 'bg-medium-latency'
|
||||
} else {
|
||||
return 'bg-high-latency'
|
||||
}
|
||||
}
|
||||
|
||||
const goodsCounts = computed(() => {
|
||||
return nodesLatency.value.filter(
|
||||
(node) => node.latency < lowLatency.value && node.latency > NOT_CONNECTED,
|
||||
).length
|
||||
})
|
||||
const mediumCounts = computed(() => {
|
||||
return nodesLatency.value.filter(
|
||||
(node) => node.latency >= lowLatency.value && node.latency < mediumLatency.value,
|
||||
).length
|
||||
})
|
||||
const badCounts = computed(() => {
|
||||
return nodesLatency.value.filter((node) => node.latency >= mediumLatency.value).length
|
||||
})
|
||||
const notConnectedCounts = computed(() => {
|
||||
return nodesLatency.value.filter((node) => node.latency === NOT_CONNECTED).length
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tooltip:before {
|
||||
left: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
</style>
|
||||
145
src/components/proxies/ProxyProvider.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<CollapseCard :name="proxyProvider.name">
|
||||
<template v-slot:title>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-xl font-medium">
|
||||
{{ proxyProvider.name }}
|
||||
<span class="text-base-content/60 text-sm font-normal"> ({{ proxiesCount }}) </span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
:class="twMerge('btn btn-circle btn-sm z-30')"
|
||||
@click.stop="healthCheckClickHandler"
|
||||
>
|
||||
<span
|
||||
v-if="isHealthChecking"
|
||||
class="loading loading-spinner loading-xs"
|
||||
></span>
|
||||
<BoltIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="proxyProvider.vehicleType !== 'Inline'"
|
||||
:class="twMerge('btn btn-circle btn-sm z-30', isUpdating ? 'animate-spin' : '')"
|
||||
@click.stop="updateProviderClickHandler"
|
||||
>
|
||||
<ArrowPathIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-base-content/60 flex items-end justify-between text-sm max-sm:flex-col max-sm:items-start"
|
||||
>
|
||||
<div class="min-h-10">
|
||||
<div v-if="subscriptionInfo">
|
||||
{{ subscriptionInfo.expireStr }}
|
||||
</div>
|
||||
<div v-if="subscriptionInfo">
|
||||
{{ subscriptionInfo.usageStr }}
|
||||
</div>
|
||||
</div>
|
||||
<div>{{ $t('updated') }} {{ fromNow(proxyProvider.updatedAt) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:preview>
|
||||
<ProxyPreview :nodes="renderProxies" />
|
||||
</template>
|
||||
<template v-slot:content>
|
||||
<ProxiesContent
|
||||
:name="name"
|
||||
:render-proxies="renderProxies"
|
||||
/>
|
||||
</template>
|
||||
</CollapseCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { proxyProviderHealthCheckAPI, updateProxyProviderAPI } from '@/api'
|
||||
import { useBounceOnVisible } from '@/composables/bouncein'
|
||||
import { useRenderProxies } from '@/composables/renderProxies'
|
||||
import { fromNow, prettyBytesHelper } from '@/helper/utils'
|
||||
import { fetchProxies, proxyProviederList } from '@/store/proxies'
|
||||
import { ArrowPathIcon, BoltIcon } from '@heroicons/vue/24/outline'
|
||||
import dayjs from 'dayjs'
|
||||
import { toFinite } from 'lodash'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import CollapseCard from '../common/CollapseCard.vue'
|
||||
import ProxiesContent from './ProxiesContent.vue'
|
||||
import ProxyPreview from './ProxyPreview.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
}>()
|
||||
|
||||
const proxyProvider = computed(
|
||||
() => proxyProviederList.value.find((group) => group.name === props.name)!,
|
||||
)
|
||||
const allProxies = computed(() => proxyProvider.value.proxies.map((node) => node.name) ?? [])
|
||||
const { renderProxies, proxiesCount } = useRenderProxies(allProxies)
|
||||
|
||||
const subscriptionInfo = computed(() => {
|
||||
const info = proxyProvider.value.subscriptionInfo
|
||||
|
||||
if (info) {
|
||||
const { Download = 0, Upload = 0, Total = 0, Expire = 0 } = info
|
||||
|
||||
if (Download === 0 && Upload === 0 && Total === 0 && Expire === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const total = prettyBytesHelper(Total, { binary: true })
|
||||
const used = prettyBytesHelper(Download + Upload, { binary: true })
|
||||
const percentage = toFinite((((Download + Upload) / Total) * 100).toFixed(2))
|
||||
const expireStr =
|
||||
Expire === 0
|
||||
? `${t('expire')}: ${t('noExpire')}`
|
||||
: `${t('expire')}: ${dayjs(Expire * 1000).format('YYYY-MM-DD')}`
|
||||
|
||||
const usedStr = `${used} / ${total}`
|
||||
const usageStr = Total === 0 ? usedStr : `${usedStr} ( ${percentage}% )`
|
||||
|
||||
return {
|
||||
expireStr,
|
||||
usageStr,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const isHealthChecking = ref(false)
|
||||
|
||||
const healthCheckClickHandler = async () => {
|
||||
if (isHealthChecking.value) return
|
||||
|
||||
isHealthChecking.value = true
|
||||
try {
|
||||
await proxyProviderHealthCheckAPI(props.name)
|
||||
await fetchProxies()
|
||||
isHealthChecking.value = false
|
||||
} catch {
|
||||
isHealthChecking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateProviderClickHandler = async () => {
|
||||
if (isUpdating.value) return
|
||||
|
||||
isUpdating.value = true
|
||||
try {
|
||||
await updateProxyProviderAPI(props.name)
|
||||
await fetchProxies()
|
||||
isUpdating.value = false
|
||||
} catch {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
useBounceOnVisible()
|
||||
</script>
|
||||
292
src/components/rules/RuleCard.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div
|
||||
class="card"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-2 overflow-hidden p-2 text-sm"
|
||||
:class="{
|
||||
'cursor-pointer': isSelectable,
|
||||
}"
|
||||
@click="clickHandler"
|
||||
>
|
||||
<div class="min-h-6 leading-6">
|
||||
<span>{{ index }}.</span>
|
||||
<span class="ml-2">{{ rule.type }}</span>
|
||||
<span
|
||||
class="text-main ml-2"
|
||||
v-if="rule.payload"
|
||||
>
|
||||
{{ rule.payload }}
|
||||
</span>
|
||||
<span
|
||||
v-if="typeof size === 'number' && size !== -1"
|
||||
class="text-base-content/80 ml-1 text-xs"
|
||||
>
|
||||
({{ size }})
|
||||
<QuestionMarkCircleIcon
|
||||
v-if="size === 0"
|
||||
class="-mt-1 ml-1 inline-block h-4 w-4"
|
||||
@mouseenter="showMMDBSizeTip"
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
v-if="isUpdateableRuleSet"
|
||||
:class="
|
||||
twMerge(
|
||||
'btn btn-circle btn-ghost btn-xs -mt-[2px] ml-1',
|
||||
isUpdating ? 'animate-spin' : '',
|
||||
)
|
||||
"
|
||||
@click.stop="updateRuleProviderClickHandler"
|
||||
>
|
||||
<ArrowPathIcon class="h-4 w-4" />
|
||||
</button>
|
||||
<InformationCircleIcon
|
||||
v-if="rule.extra"
|
||||
class="-mt-[2px] ml-1 inline-block h-4 w-4"
|
||||
@mouseenter="showRuleHitInfoTip"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-h-6 flex-wrap items-center gap-1 md:gap-2">
|
||||
<input
|
||||
v-if="rule.uuid || rule.extra"
|
||||
type="checkbox"
|
||||
class="toggle toggle-sm"
|
||||
:checked="!isDisabled"
|
||||
@change="toggleRuleDisabledHandler"
|
||||
@click.stop
|
||||
/>
|
||||
<ProxyName
|
||||
v-if="isCollapsed"
|
||||
:name="rule.proxy"
|
||||
class="badge gap-0 text-xs"
|
||||
/>
|
||||
<template v-if="!isCollapsed">
|
||||
<template
|
||||
v-for="(chain, index) in proxyChains"
|
||||
:key="chain"
|
||||
>
|
||||
<ArrowRightCircleIcon
|
||||
class="h-4 w-4"
|
||||
v-if="index > 0"
|
||||
/>
|
||||
<ProxyName
|
||||
:name="chain"
|
||||
class="badge gap-0 text-xs"
|
||||
:class="{
|
||||
'bg-neutral text-neutral-content': selected === chain,
|
||||
}"
|
||||
@click.stop="selected = chain"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<template v-if="proxyNode?.now && displayNowNodeInRule">
|
||||
<ArrowRightCircleIcon class="h-4 w-4" />
|
||||
<ProxyName
|
||||
:name="getNowProxyNodeName(rule.proxy)"
|
||||
class="badge cursor-not-allowed gap-0 text-xs"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="latency !== NOT_CONNECTED && displayLatencyInRule"
|
||||
:class="latencyColor"
|
||||
class="ml-1 text-xs"
|
||||
>
|
||||
{{ latency }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="isSelectable && !isCollapsed">
|
||||
<div class="border-base-content/15 border-b"></div>
|
||||
<ProxyGroup
|
||||
:name="selected"
|
||||
class="transparent-collapse"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
disconnectByIdAPI,
|
||||
toggleRuleDisabledAPI,
|
||||
toggleRuleDisabledSingBoxAPI,
|
||||
updateRuleProviderAPI,
|
||||
} from '@/api'
|
||||
import { useBounceOnVisible } from '@/composables/bouncein'
|
||||
import { NOT_CONNECTED } from '@/constant'
|
||||
import { getColorForLatency } from '@/helper'
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import { activeConnections } from '@/store/connections'
|
||||
import {
|
||||
getLatencyByName,
|
||||
getNowProxyNodeName,
|
||||
getProxyGroupChains,
|
||||
proxyGroupList,
|
||||
proxyMap,
|
||||
} from '@/store/proxies'
|
||||
import { fetchRules, ruleProviderList } from '@/store/rules'
|
||||
import {
|
||||
disconnectOnRuleDisable,
|
||||
displayLatencyInRule,
|
||||
displayNowNodeInRule,
|
||||
} from '@/store/settings'
|
||||
import type { Rule } from '@/types'
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowRightCircleIcon,
|
||||
InformationCircleIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import dayjs from 'dayjs'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { computed, createApp, defineComponent, h, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ProxyGroup from '../proxies/ProxyGroup.vue'
|
||||
import ProxyName from '../proxies/ProxyName.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
rule: Rule
|
||||
index: number
|
||||
}>()
|
||||
|
||||
const isCollapsed = ref(true)
|
||||
const isSelectable = computed(() => proxyGroupList.value.includes(props.rule.proxy))
|
||||
const selected = ref('')
|
||||
const proxyChains = computed(() => getProxyGroupChains(props.rule.proxy))
|
||||
|
||||
const { t } = useI18n()
|
||||
const { showTip } = useTooltip()
|
||||
const proxyNode = computed(() => proxyMap.value[props.rule.proxy])
|
||||
const latency = computed(() => getLatencyByName(props.rule.proxy, props.rule.proxy))
|
||||
const latencyColor = computed(() => getColorForLatency(Number(latency.value)))
|
||||
|
||||
const size = computed(() => {
|
||||
if (props.rule.type === 'RuleSet') {
|
||||
return ruleProviderList.value.find((provider) => provider.name === props.rule.payload)
|
||||
?.ruleCount
|
||||
}
|
||||
|
||||
return props.rule.size
|
||||
})
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const isTogglingDisabled = ref(false)
|
||||
const isDisabled = computed(() => {
|
||||
const rule = props.rule
|
||||
|
||||
if (rule.extra) {
|
||||
return rule.extra.disabled
|
||||
}
|
||||
|
||||
return rule.disabled
|
||||
})
|
||||
|
||||
const isUpdateableRuleSet = computed(() => {
|
||||
if (props.rule.type !== 'RuleSet') {
|
||||
return false
|
||||
}
|
||||
|
||||
const provider = ruleProviderList.value.find((provider) => provider.name === props.rule.payload)
|
||||
|
||||
if (!provider) {
|
||||
return false
|
||||
}
|
||||
return provider.vehicleType !== 'Inline'
|
||||
})
|
||||
|
||||
const updateRuleProviderClickHandler = async () => {
|
||||
if (isUpdating.value) return
|
||||
|
||||
isUpdating.value = true
|
||||
await updateRuleProviderAPI(props.rule.payload)
|
||||
await fetchRules()
|
||||
isUpdating.value = false
|
||||
}
|
||||
|
||||
const toggleRuleDisabledHandler = async () => {
|
||||
if (isTogglingDisabled.value) return
|
||||
|
||||
try {
|
||||
isTogglingDisabled.value = true
|
||||
const willBeDisabled = !isDisabled.value
|
||||
|
||||
if (props.rule.uuid) {
|
||||
await toggleRuleDisabledSingBoxAPI(props.rule.uuid)
|
||||
} else {
|
||||
await toggleRuleDisabledAPI({ [props.rule.index]: willBeDisabled })
|
||||
}
|
||||
|
||||
if (willBeDisabled && disconnectOnRuleDisable.value) {
|
||||
const matchingConnections = activeConnections.value.filter((conn) => {
|
||||
const ruleTypeMatches = conn.rule === props.rule.type
|
||||
const rulePayloadMatches = (conn.rulePayload || '') === (props.rule.payload || '')
|
||||
return ruleTypeMatches && rulePayloadMatches
|
||||
})
|
||||
|
||||
if (matchingConnections.length > 0) {
|
||||
matchingConnections.forEach((conn) => disconnectByIdAPI(conn.id))
|
||||
}
|
||||
}
|
||||
|
||||
await fetchRules()
|
||||
} finally {
|
||||
isTogglingDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showMMDBSizeTip = (e: Event) => {
|
||||
showTip(e, t('mmdbSizeTip'))
|
||||
}
|
||||
|
||||
const ruleHitCount = computed(() => t('ruleHitCount', { count: props.rule.extra?.hitCount }))
|
||||
const ruleLastHit = computed(() =>
|
||||
t('ruleLastHit', { time: dayjs(props.rule.extra?.hitAt).format('YYYY-MM-DD HH:mm:ss') }),
|
||||
)
|
||||
const ruleMissCount = computed(() => t('ruleMissCount', { count: props.rule.extra?.missCount }))
|
||||
const ruleLastMiss = computed(() =>
|
||||
t('ruleLastMiss', { time: dayjs(props.rule.extra?.missAt).format('YYYY-MM-DD HH:mm:ss') }),
|
||||
)
|
||||
|
||||
const showRuleHitInfoTip = (e: Event) => {
|
||||
if (!props.rule.extra) return
|
||||
|
||||
const PopContent = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h('div', { class: 'flex flex-col gap-2 text-sm' }, [
|
||||
h('div', { class: 'flex flex-col gap-1' }, [
|
||||
h('div', ruleHitCount.value),
|
||||
h('div', ruleLastHit.value),
|
||||
]),
|
||||
h('div', { class: 'flex flex-col gap-1' }, [
|
||||
h('div', ruleMissCount.value),
|
||||
h('div', ruleLastMiss.value),
|
||||
]),
|
||||
])
|
||||
},
|
||||
})
|
||||
const mountEl = document.createElement('div')
|
||||
const app = createApp(PopContent)
|
||||
|
||||
app.mount(mountEl)
|
||||
|
||||
showTip(e, mountEl, {
|
||||
delay: [500, 0],
|
||||
trigger: 'mouseenter',
|
||||
})
|
||||
}
|
||||
|
||||
const clickHandler = () => {
|
||||
if (isSelectable.value && !props.rule.disabled) {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
selected.value = props.rule.proxy
|
||||
}
|
||||
}
|
||||
|
||||
useBounceOnVisible()
|
||||
</script>
|
||||
58
src/components/rules/RuleProvider.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="card hover:bg-base-200 w-full gap-2 p-2 text-sm">
|
||||
<div class="flex h-6 items-center gap-2 leading-6">
|
||||
<span>{{ index }}.</span>
|
||||
<span class="text-main">{{ ruleProvider.name }}</span>
|
||||
<span class="text-base-content/80 text-xs"> ({{ ruleProvider.ruleCount }}) </span>
|
||||
<button
|
||||
v-if="ruleProvider.vehicleType !== 'Inline'"
|
||||
:class="twMerge('btn btn-circle btn-xs btn-ghost', isUpdating ? 'animate-spin' : '')"
|
||||
@click="updateRuleProviderClickHandler"
|
||||
>
|
||||
<ArrowPathIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-base-content/80 flex h-5 items-center gap-2 text-xs">
|
||||
<span
|
||||
v-if="ruleProvider.behavior"
|
||||
class="badge badge-sm min-w-16"
|
||||
>
|
||||
{{ ruleProvider.behavior }}
|
||||
</span>
|
||||
<span
|
||||
v-if="ruleProvider.vehicleType"
|
||||
class="badge badge-sm min-w-12"
|
||||
>
|
||||
{{ ruleProvider.vehicleType }}
|
||||
</span>
|
||||
<span>{{ $t('updated') }} {{ fromNow(ruleProvider.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { updateRuleProviderAPI } from '@/api'
|
||||
import { useBounceOnVisible } from '@/composables/bouncein'
|
||||
import { fromNow } from '@/helper/utils'
|
||||
import { fetchRules } from '@/store/rules'
|
||||
import type { RuleProvider } from '@/types'
|
||||
import { ArrowPathIcon } from '@heroicons/vue/24/outline'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { ref } from 'vue'
|
||||
const isUpdating = ref(false)
|
||||
const props = defineProps<{
|
||||
ruleProvider: RuleProvider
|
||||
index: number
|
||||
}>()
|
||||
|
||||
const updateRuleProviderClickHandler = async () => {
|
||||
if (isUpdating.value) return
|
||||
|
||||
isUpdating.value = true
|
||||
await updateRuleProviderAPI(props.ruleProvider.name)
|
||||
fetchRules()
|
||||
isUpdating.value = false
|
||||
}
|
||||
|
||||
useBounceOnVisible()
|
||||
</script>
|
||||
373
src/components/settings/BackendSettings.vue
Normal file
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<!-- backend -->
|
||||
<div
|
||||
v-if="hasVisibleItems"
|
||||
class="flex flex-col gap-2 p-4 text-sm"
|
||||
>
|
||||
<div class="settings-title">
|
||||
<div class="indicator">
|
||||
<span
|
||||
v-if="isCoreUpdateAvailable"
|
||||
class="indicator-item top-1 -right-1 flex"
|
||||
>
|
||||
<span class="bg-secondary absolute h-2 w-2 animate-ping rounded-full"></span>
|
||||
<span class="bg-secondary h-2 w-2 rounded-full"></span>
|
||||
</span>
|
||||
<a
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
:href="
|
||||
isSingBox
|
||||
? 'https://github.com/sagernet/sing-box'
|
||||
: 'https://github.com/metacubex/mihomo'
|
||||
"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('backend') }}
|
||||
<BackendVersion class="text-sm font-normal" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<BackendSwitch v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.backend}.backendSwitch`]" />
|
||||
|
||||
<template
|
||||
v-if="!isSingBox && configs && !hiddenSettingsItems[`${SETTINGS_MENU_KEY.backend}.ports`]"
|
||||
>
|
||||
<div class="divider"></div>
|
||||
<div
|
||||
class="grid max-w-3xl gap-2 gap-x-6"
|
||||
:style="`grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));`"
|
||||
>
|
||||
<div
|
||||
class="setting-item"
|
||||
v-for="portConfig in portList"
|
||||
:key="portConfig.key"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t(portConfig.label) }}
|
||||
</div>
|
||||
<input
|
||||
class="input input-sm w-20 sm:w-24"
|
||||
type="number"
|
||||
v-model="configs[portConfig.key as keyof Config]"
|
||||
@change="
|
||||
updateConfigs({ [portConfig.key]: Number(configs[portConfig.key as keyof Config]) })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid max-w-3xl gap-2 gap-x-6"
|
||||
:style="`grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));`"
|
||||
>
|
||||
<div
|
||||
v-if="configs?.tun && !hiddenSettingsItems[`${SETTINGS_MENU_KEY.backend}.tunMode`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('tunMode') }}
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="configs.tun.enable"
|
||||
@change="hanlderTunModeChange"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.backend}.allowLan`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('allowLan') }}
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="configs['allow-lan']"
|
||||
@change="handlerAllowLanChange"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="!activeBackend?.disableUpgradeCore">
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.backend}.checkUpgrade`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('checkUpgrade') }}
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="checkUpgradeCore"
|
||||
@change="handlerCheckUpgradeCoreChange"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
checkUpgradeCore && !hiddenSettingsItems[`${SETTINGS_MENU_KEY.backend}.autoUpgrade`]
|
||||
"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('autoUpgrade') }}
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="autoUpgradeCore"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.backend}.actions`]"
|
||||
class="divider"
|
||||
></div>
|
||||
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.backend}.actions`]"
|
||||
class="grid max-w-6xl gap-2 gap-y-3"
|
||||
:style="`grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));`"
|
||||
>
|
||||
<template v-if="!isSingBox || displayAllFeatures">
|
||||
<button
|
||||
v-if="!activeBackend?.disableUpgradeCore"
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="showUpgradeCoreModal = true"
|
||||
>
|
||||
{{ $t('upgradeCore') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="handlerClickRestartCore"
|
||||
>
|
||||
<span
|
||||
v-if="isCoreRestarting"
|
||||
class="loading loading-spinner loading-md"
|
||||
></span>
|
||||
{{ $t('restartCore') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="handlerClickReloadConfigs"
|
||||
>
|
||||
<span
|
||||
v-if="isConfigReloading"
|
||||
class="loading loading-spinner loading-md"
|
||||
></span>
|
||||
{{ $t('reloadConfigs') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="handlerClickUpdateGeo"
|
||||
>
|
||||
<span
|
||||
v-if="isGeoUpdating"
|
||||
class="loading loading-spinner loading-md"
|
||||
></span>
|
||||
{{ $t('updateGeoDatabase') }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="handleFlushDNSCache"
|
||||
>
|
||||
{{ $t('flushDNSCache') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="handleFlushFakeIP"
|
||||
>
|
||||
{{ $t('flushFakeIP') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasSmartGroup"
|
||||
class="btn btn-sm"
|
||||
@click="flushSmartGroupWeightsAPI"
|
||||
>
|
||||
{{ $t('flushSmartWeights') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.backend}.dnsQuery`]"
|
||||
class="divider"
|
||||
></div>
|
||||
<DnsQuery v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.backend}.dnsQuery`]" />
|
||||
<UpgradeCoreModal v-model="showUpgradeCoreModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
flushDNSCacheAPI,
|
||||
flushFakeIPAPI,
|
||||
flushSmartGroupWeightsAPI,
|
||||
isCoreUpdateAvailable,
|
||||
isSingBox,
|
||||
reloadConfigsAPI,
|
||||
restartCoreAPI,
|
||||
updateGeoDataAPI,
|
||||
} from '@/api'
|
||||
import BackendVersion from '@/components/common/BackendVersion.vue'
|
||||
import BackendSwitch from '@/components/settings/BackendSwitch.vue'
|
||||
import DnsQuery from '@/components/settings/DnsQuery.vue'
|
||||
import { SETTINGS_MENU_KEY } from '@/constant'
|
||||
import { showNotification } from '@/helper/notification'
|
||||
import { configs, fetchConfigs, updateConfigs } from '@/store/config'
|
||||
import { fetchProxies, hasSmartGroup } from '@/store/proxies'
|
||||
import { fetchRules } from '@/store/rules'
|
||||
import {
|
||||
autoUpgradeCore,
|
||||
checkUpgradeCore,
|
||||
displayAllFeatures,
|
||||
hiddenSettingsItems,
|
||||
} from '@/store/settings'
|
||||
import { activeBackend } from '@/store/setup'
|
||||
import type { Config } from '@/types'
|
||||
import { computed, ref } from 'vue'
|
||||
import UpgradeCoreModal from './UpgradeCoreModal.vue'
|
||||
|
||||
// 检查是否有可见的子项
|
||||
const hasVisibleItems = computed(() => {
|
||||
return (
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.backend}.backendSwitch`] ||
|
||||
(!isSingBox.value &&
|
||||
configs.value &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.backend}.ports`]) ||
|
||||
(!isSingBox.value &&
|
||||
configs.value &&
|
||||
configs.value?.tun &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.backend}.tunMode`]) ||
|
||||
(!isSingBox.value &&
|
||||
configs.value &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.backend}.allowLan`]) ||
|
||||
(!isSingBox.value &&
|
||||
configs.value &&
|
||||
!activeBackend.value?.disableUpgradeCore &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.backend}.checkUpgrade`]) ||
|
||||
(!isSingBox.value &&
|
||||
configs.value &&
|
||||
!activeBackend.value?.disableUpgradeCore &&
|
||||
checkUpgradeCore.value &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.backend}.autoUpgrade`]) ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.backend}.actions`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.backend}.dnsQuery`]
|
||||
)
|
||||
})
|
||||
|
||||
const portList = [
|
||||
{
|
||||
label: 'mixedPort',
|
||||
key: 'mixed-port',
|
||||
},
|
||||
{
|
||||
label: 'httpPort',
|
||||
key: 'port',
|
||||
},
|
||||
{
|
||||
label: 'socksPort',
|
||||
key: 'socks-port',
|
||||
},
|
||||
{
|
||||
label: 'redirPort',
|
||||
key: 'redir-port',
|
||||
},
|
||||
{
|
||||
label: 'tproxyPort',
|
||||
key: 'tproxy-port',
|
||||
},
|
||||
]
|
||||
|
||||
const reloadAll = () => {
|
||||
fetchConfigs()
|
||||
fetchRules()
|
||||
fetchProxies()
|
||||
}
|
||||
|
||||
const showUpgradeCoreModal = ref(false)
|
||||
|
||||
const isCoreRestarting = ref(false)
|
||||
const handlerClickRestartCore = async () => {
|
||||
if (isCoreRestarting.value) return
|
||||
isCoreRestarting.value = true
|
||||
try {
|
||||
await restartCoreAPI()
|
||||
setTimeout(() => {
|
||||
reloadAll()
|
||||
}, 500)
|
||||
isCoreRestarting.value = false
|
||||
showNotification({
|
||||
content: 'restartCoreSuccess',
|
||||
type: 'alert-success',
|
||||
})
|
||||
} catch {
|
||||
isCoreRestarting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isConfigReloading = ref(false)
|
||||
const handlerClickReloadConfigs = async () => {
|
||||
if (isConfigReloading.value) return
|
||||
isConfigReloading.value = true
|
||||
try {
|
||||
await reloadConfigsAPI()
|
||||
reloadAll()
|
||||
isConfigReloading.value = false
|
||||
showNotification({
|
||||
content: 'reloadConfigsSuccess',
|
||||
type: 'alert-success',
|
||||
})
|
||||
} catch {
|
||||
isConfigReloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isGeoUpdating = ref(false)
|
||||
const handlerClickUpdateGeo = async () => {
|
||||
if (isGeoUpdating.value) return
|
||||
isGeoUpdating.value = true
|
||||
try {
|
||||
await updateGeoDataAPI()
|
||||
reloadAll()
|
||||
isGeoUpdating.value = false
|
||||
showNotification({
|
||||
content: 'updateGeoSuccess',
|
||||
type: 'alert-success',
|
||||
})
|
||||
} catch {
|
||||
isGeoUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlerCheckUpgradeCoreChange = () => {
|
||||
if (!checkUpgradeCore.value) {
|
||||
autoUpgradeCore.value = false
|
||||
isCoreUpdateAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hanlderTunModeChange = async () => {
|
||||
await updateConfigs({ tun: { enable: configs.value?.tun.enable } })
|
||||
}
|
||||
const handlerAllowLanChange = async () => {
|
||||
await updateConfigs({ ['allow-lan']: configs.value?.['allow-lan'] })
|
||||
}
|
||||
|
||||
const handleFlushDNSCache = async () => {
|
||||
await flushDNSCacheAPI()
|
||||
showNotification({
|
||||
content: 'flushDNSCacheSuccess',
|
||||
type: 'alert-success',
|
||||
})
|
||||
}
|
||||
|
||||
const handleFlushFakeIP = async () => {
|
||||
await flushFakeIPAPI()
|
||||
showNotification({
|
||||
content: 'flushFakeIPSuccess',
|
||||
type: 'alert-success',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
73
src/components/settings/BackendSwitch.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="join flex">
|
||||
<select
|
||||
class="join-item select select-sm w-46 max-w-60 flex-1"
|
||||
v-model="activeUuid"
|
||||
>
|
||||
<option
|
||||
v-for="opt in opts"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="!disableEditBackend"
|
||||
class="btn join-item btn-sm"
|
||||
@click="editBackend"
|
||||
:disabled="!activeBackend"
|
||||
>
|
||||
<PencilIcon class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn join-item btn-sm"
|
||||
@click="addBackend"
|
||||
>
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 编辑Backend Modal -->
|
||||
<EditBackendModal v-model="showEditModal" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ROUTE_NAME } from '@/constant'
|
||||
import { getLabelFromBackend } from '@/helper/utils'
|
||||
import router from '@/router'
|
||||
import { activeBackend, activeUuid, backendList } from '@/store/setup'
|
||||
import { PencilIcon, PlusIcon } from '@heroicons/vue/24/outline'
|
||||
import { computed, ref } from 'vue'
|
||||
import EditBackendModal from './EditBackendModal.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
disableEditBackend?: boolean
|
||||
}>(),
|
||||
{
|
||||
disableEditBackend: false,
|
||||
},
|
||||
)
|
||||
|
||||
const opts = computed(() => {
|
||||
return backendList.value.map((b) => {
|
||||
return {
|
||||
label: getLabelFromBackend(b),
|
||||
value: b.uuid,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const showEditModal = ref(false)
|
||||
|
||||
const addBackend = () => {
|
||||
activeUuid.value = null
|
||||
router.push({ name: ROUTE_NAME.setup })
|
||||
}
|
||||
|
||||
const editBackend = () => {
|
||||
if (!activeBackend.value) return
|
||||
showEditModal.value = true
|
||||
}
|
||||
</script>
|
||||
102
src/components/settings/ConnectionCardSettings.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<span>{{ $t('customCardLines') }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="((connectionCardLines = SIMPLE_CARD_STYLE), setRestOfColumns())"
|
||||
>
|
||||
{{ $t('simpleCardPreset') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="((connectionCardLines = DETAILED_CARD_STYLE), setRestOfColumns())"
|
||||
>
|
||||
{{ $t('detailedCardPreset') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex flex-col rounded-sm">
|
||||
<div
|
||||
v-for="(_, index) in connectionCardLines"
|
||||
:key="index"
|
||||
:class="`flex items-center gap-2 p-2 ${index % 2 === 0 ? 'bg-base-200' : 'bg-base-300'}`"
|
||||
>
|
||||
<button
|
||||
v-if="connectionCardLines.length > 1"
|
||||
class="btn btn-circle bg-base-100 hover:bg-base-200 btn-sm shadow-sm"
|
||||
@click="removeLine(index)"
|
||||
>
|
||||
<TrashIcon class="h-4 w-4" />
|
||||
</button>
|
||||
<Draggable
|
||||
class="flex flex-1 flex-wrap items-center gap-2"
|
||||
v-model="connectionCardLines[index]"
|
||||
:animation="150"
|
||||
group="list"
|
||||
ghostClass="ghost"
|
||||
:item-key="(id: string) => id"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<button class="btn btn-sm bg-base-100 hover:bg-base-200 cursor-move shadow-sm">
|
||||
{{ $t(element) }}
|
||||
</button>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
<div :class="`p-2 ${connectionCardLines.length % 2 === 1 ? 'bg-base-300' : 'bg-base-200'}`">
|
||||
<button
|
||||
class="btn btn-circle bg-base-100 hover:bg-base-200 btn-sm shadow-sm"
|
||||
@click="addLine"
|
||||
>
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Draggable
|
||||
class="flex flex-1 flex-wrap gap-2 p-2"
|
||||
v-model="restOfColumns"
|
||||
:animation="150"
|
||||
group="list"
|
||||
ghostClass="ghost"
|
||||
:item-key="(id: string) => id"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<button class="btn btn-sm cursor-move">
|
||||
{{ $t(element) }}
|
||||
</button>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CONNECTIONS_TABLE_ACCESSOR_KEY, DETAILED_CARD_STYLE, SIMPLE_CARD_STYLE } from '@/constant'
|
||||
import { connectionCardLines } from '@/store/settings'
|
||||
import { PlusIcon, TrashIcon } from '@heroicons/vue/24/outline'
|
||||
import { ref } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
const restOfColumns = ref<CONNECTIONS_TABLE_ACCESSOR_KEY[]>([])
|
||||
|
||||
const setRestOfColumns = () => {
|
||||
restOfColumns.value = Object.values(CONNECTIONS_TABLE_ACCESSOR_KEY).filter(
|
||||
(key) => !connectionCardLines.value.flat().includes(key),
|
||||
)
|
||||
}
|
||||
|
||||
setRestOfColumns()
|
||||
|
||||
const addLine = () => {
|
||||
connectionCardLines.value = [
|
||||
...connectionCardLines.value,
|
||||
restOfColumns.value[0] ? [restOfColumns.value[0]] : [],
|
||||
]
|
||||
setRestOfColumns()
|
||||
}
|
||||
|
||||
const removeLine = (index: number) => {
|
||||
connectionCardLines.value.splice(index, 1)
|
||||
setRestOfColumns()
|
||||
}
|
||||
</script>
|
||||
130
src/components/settings/ConnectionsSettings.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<!-- connections -->
|
||||
<div
|
||||
v-if="hasVisibleItems"
|
||||
class="flex flex-col gap-2 p-4 text-sm"
|
||||
>
|
||||
<div class="settings-title">
|
||||
{{ $t('connections') }}
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.connections}.connectionStyle`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('connectionStyle') }}
|
||||
</div>
|
||||
<select
|
||||
class="select select-sm min-w-24"
|
||||
v-model="useConnectionCard"
|
||||
>
|
||||
<option :value="false">
|
||||
{{ $t('table') }}
|
||||
</option>
|
||||
<option :value="true">
|
||||
{{ $t('card') }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.connections}.proxyChainDirection`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('proxyChainDirection') }}
|
||||
</div>
|
||||
<select
|
||||
class="select select-sm w-24"
|
||||
v-model="proxyChainDirection"
|
||||
>
|
||||
<option
|
||||
v-for="opt in Object.values(PROXY_CHAIN_DIRECTION)"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ $t(opt) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!useConnectionCard"
|
||||
class="settings-grid"
|
||||
>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.connections}.tableWidthMode`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('tableWidthMode') }}
|
||||
</div>
|
||||
<select
|
||||
class="select select-sm min-w-24"
|
||||
v-model="tableWidthMode"
|
||||
>
|
||||
<option
|
||||
v-for="opt in Object.values(TABLE_WIDTH_MODE)"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ $t(opt) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.connections}.tableSize`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('tableSize') }}
|
||||
</div>
|
||||
<select
|
||||
class="select select-sm min-w-24"
|
||||
v-model="tableSize"
|
||||
>
|
||||
<option
|
||||
v-for="opt in Object.values(TABLE_SIZE)"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ $t(opt) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.connections}.sourceIPLabels`]"
|
||||
class="divider"
|
||||
></div>
|
||||
<SourceIPLabels
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.connections}.sourceIPLabels`]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SourceIPLabels from '@/components/settings/SourceIPLabels.vue'
|
||||
import { PROXY_CHAIN_DIRECTION, SETTINGS_MENU_KEY, TABLE_SIZE, TABLE_WIDTH_MODE } from '@/constant'
|
||||
import {
|
||||
hiddenSettingsItems,
|
||||
proxyChainDirection,
|
||||
tableSize,
|
||||
tableWidthMode,
|
||||
useConnectionCard,
|
||||
} from '@/store/settings'
|
||||
import { computed } from 'vue'
|
||||
|
||||
// 检查是否有可见的子项
|
||||
const hasVisibleItems = computed(() => {
|
||||
return (
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.connections}.connectionStyle`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.connections}.proxyChainDirection`] ||
|
||||
(!useConnectionCard.value &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.connections}.tableWidthMode`]) ||
|
||||
(!useConnectionCard.value &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.connections}.tableSize`]) ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.connections}.sourceIPLabels`]
|
||||
)
|
||||
})
|
||||
</script>
|
||||
212
src/components/settings/CustomTheme.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<DialogWrapper
|
||||
v-model="model"
|
||||
:title="$t('customTheme')"
|
||||
>
|
||||
<div class="divider">Color</div>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<div
|
||||
v-for="color in colors"
|
||||
:key="color"
|
||||
>
|
||||
<label class="flex cursor-pointer flex-col items-center gap-1">
|
||||
<div class="flex h-6 items-center justify-center text-xs">
|
||||
{{ color.replace('--color-', '') }}
|
||||
</div>
|
||||
<div
|
||||
class="border-base-content h-6 w-6 rounded border-2"
|
||||
:style="`background-color: ${customTheme[color as keyof typeof customTheme]};`"
|
||||
></div>
|
||||
<input
|
||||
class="h-1 w-1 opacity-0"
|
||||
:key="color"
|
||||
type="color"
|
||||
v-model="customTheme[color as keyof typeof customTheme]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider">Radius</div>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<div
|
||||
v-for="radius in radiusKey"
|
||||
:key="radius"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{{ radius.replace('--radius-', '') }}
|
||||
<TextInput
|
||||
class="w-20"
|
||||
v-model="customTheme[radius as keyof typeof customTheme] as string"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider">Size</div>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<div
|
||||
v-for="size in sizeKey"
|
||||
:key="size"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{{ size.replace('--size-', '') }}
|
||||
<TextInput
|
||||
class="w-20"
|
||||
v-model="customTheme[size as keyof typeof customTheme] as string"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
border
|
||||
<TextInput
|
||||
class="w-20"
|
||||
v-model="customTheme['--border']"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider">Effect</div>
|
||||
<div class="grid grid-cols-1 gap-2 pb-12 md:grid-cols-3">
|
||||
<div>
|
||||
depth
|
||||
<input
|
||||
class="toggle"
|
||||
v-model="depth"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
noise
|
||||
<input
|
||||
class="toggle"
|
||||
v-model="noise"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
dark
|
||||
<input
|
||||
class="toggle"
|
||||
v-model="dark"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-base-100 border-base-200 absolute right-0 bottom-0 left-0 flex gap-2 border-t p-2 pt-2"
|
||||
>
|
||||
<select
|
||||
class="select select-sm w-26"
|
||||
v-model="applyFrom"
|
||||
>
|
||||
<option
|
||||
v-for="opt in ALL_THEME"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="resetCustomTheme"
|
||||
>
|
||||
{{ $t('reset') }}
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<a
|
||||
class="btn btn-sm"
|
||||
href="https://daisyui.com/theme-generator/"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('moreDetails') }}
|
||||
</a>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
@click="handlerCustomThemeSave"
|
||||
>
|
||||
{{ $t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ALL_THEME, DEFAULT_THEME, type THEME } from '@/constant'
|
||||
import { applyCustomThemes } from '@/helper'
|
||||
import { customThemes, darkTheme, defaultTheme } from '@/store/settings'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import DialogWrapper from '../common/DialogWrapper.vue'
|
||||
import TextInput from '../common/TextInput.vue'
|
||||
|
||||
const model = defineModel<boolean>('value', {
|
||||
default: false,
|
||||
})
|
||||
|
||||
const applyFrom = ref(ALL_THEME[0])
|
||||
const customTheme = reactive<THEME>({ ...(customThemes.value[0] || DEFAULT_THEME) })
|
||||
const colors = computed(() => {
|
||||
return Object.keys(customTheme).filter((key) => key.startsWith('--color-'))
|
||||
})
|
||||
const radiusKey = computed(() => {
|
||||
return Object.keys(customTheme).filter((key) => key.startsWith('--radius-'))
|
||||
})
|
||||
const sizeKey = computed(() => {
|
||||
return Object.keys(customTheme).filter((key) => key.startsWith('--size-'))
|
||||
})
|
||||
|
||||
const depth = computed({
|
||||
get: () => customTheme['--depth'] === '1',
|
||||
set: (value) => {
|
||||
customTheme['--depth'] = value ? '1' : '0'
|
||||
},
|
||||
})
|
||||
|
||||
const noise = computed({
|
||||
get: () => customTheme['--noise'] === '1',
|
||||
set: (value) => {
|
||||
customTheme['--noise'] = value ? '1' : '0'
|
||||
},
|
||||
})
|
||||
|
||||
const dark = computed({
|
||||
get: () => customTheme['color-scheme'] === 'dark',
|
||||
set: (value) => {
|
||||
customTheme['color-scheme'] = value ? 'dark' : 'light'
|
||||
},
|
||||
})
|
||||
|
||||
const handlerCustomThemeSave = async () => {
|
||||
customThemes.value = [
|
||||
{
|
||||
...customTheme,
|
||||
id: uuid(),
|
||||
} as THEME,
|
||||
]
|
||||
|
||||
defaultTheme.value = ''
|
||||
darkTheme.value = ''
|
||||
|
||||
await nextTick()
|
||||
|
||||
defaultTheme.value = customTheme.name
|
||||
darkTheme.value = customTheme.name
|
||||
applyCustomThemes()
|
||||
}
|
||||
|
||||
const resetCustomTheme = () => {
|
||||
const themeElement = document.createElement('div')
|
||||
|
||||
themeElement.dataset.theme = applyFrom.value
|
||||
themeElement.style.display = 'none'
|
||||
document.body.appendChild(themeElement)
|
||||
const styles = getComputedStyle(themeElement)
|
||||
|
||||
Object.keys(DEFAULT_THEME).forEach((key) => {
|
||||
const value = styles.getPropertyValue(key).trim()
|
||||
|
||||
if (value) {
|
||||
customTheme[key] = value
|
||||
}
|
||||
})
|
||||
themeElement.remove()
|
||||
handlerCustomThemeSave()
|
||||
}
|
||||
</script>
|
||||
81
src/components/settings/DnsQuery.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="join w-96 max-sm:w-full">
|
||||
<TextInput
|
||||
v-model="form.name"
|
||||
placeholder="Domain Name"
|
||||
:clearable="true"
|
||||
/>
|
||||
<TextInput
|
||||
v-model="form.type"
|
||||
class="w-28"
|
||||
placeholder="Type"
|
||||
:menus="['A', 'AAAA', 'HTTPS']"
|
||||
/>
|
||||
<button
|
||||
class="btn join-item btn-sm"
|
||||
@click="query"
|
||||
>
|
||||
{{ $t('DNSQuery') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex max-h-96 flex-col gap-1 overflow-y-auto">
|
||||
<div
|
||||
class="flex gap-1"
|
||||
v-for="item in resultList"
|
||||
:key="item.data"
|
||||
>
|
||||
<div>{{ item.name }}</div>
|
||||
:
|
||||
<div>{{ item.data }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="details"
|
||||
class="flex gap-1"
|
||||
>
|
||||
<div
|
||||
class="mr-3 flex items-center gap-1"
|
||||
v-if="details?.country"
|
||||
>
|
||||
<MapPinIcon class="h-4 w-4 shrink-0" />
|
||||
<template v-if="details?.city && details?.city !== details?.country">
|
||||
{{ details?.city }},
|
||||
</template>
|
||||
<template v-else-if="details?.region && details?.region !== details?.country">
|
||||
{{ details?.region }},
|
||||
</template>
|
||||
{{ details?.country }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<ServerIcon class="h-4 w-4 shrink-0" />
|
||||
{{ details?.organization }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { queryDNSAPI } from '@/api'
|
||||
import { getIPInfo, type IPInfo } from '@/api/geoip'
|
||||
import type { DNSQuery } from '@/types'
|
||||
import { MapPinIcon, ServerIcon } from '@heroicons/vue/24/outline'
|
||||
import { reactive, ref } from 'vue'
|
||||
import TextInput from '../common/TextInput.vue'
|
||||
|
||||
const form = reactive({
|
||||
name: 'www.google.com',
|
||||
type: 'A',
|
||||
})
|
||||
const details = ref<IPInfo | null>(null)
|
||||
const resultList = ref<DNSQuery['Answer']>([])
|
||||
const query = async () => {
|
||||
const { data } = await queryDNSAPI(form)
|
||||
|
||||
resultList.value = data.Answer
|
||||
|
||||
if (resultList.value?.length) {
|
||||
details.value = await getIPInfo(resultList.value[0].data)
|
||||
} else {
|
||||
details.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
227
src/components/settings/EditBackendModal.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<DialogWrapper
|
||||
v-model="isVisible"
|
||||
:title="t('editBackendTitle')"
|
||||
@keydown.enter="!isSaving && handleSave()"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- 后端选择器 -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm">{{ t('selectBackend') }}</label>
|
||||
<select
|
||||
class="select select-sm w-full"
|
||||
v-model="selectedBackendUuid"
|
||||
>
|
||||
<option
|
||||
v-for="backend in backendList"
|
||||
:key="backend.uuid"
|
||||
:value="backend.uuid"
|
||||
>
|
||||
{{ getLabelFromBackend(backend) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-3"
|
||||
v-if="editForm"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm">{{ t('protocol') }}</label>
|
||||
<select
|
||||
class="select select-sm w-full"
|
||||
v-model="editForm.protocol"
|
||||
>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm">{{ t('host') }}</label>
|
||||
<TextInput
|
||||
class="w-full"
|
||||
name="username"
|
||||
v-model="editForm.host"
|
||||
placeholder="127.0.0.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm">{{ t('port') }}</label>
|
||||
<TextInput
|
||||
class="w-full"
|
||||
v-model="editForm.port"
|
||||
placeholder="9090"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm">{{ $t('secondaryPath') }} ({{ $t('optional') }})</label>
|
||||
<TextInput
|
||||
class="w-full"
|
||||
v-model="editForm.secondaryPath"
|
||||
:placeholder="t('optional')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm">{{ t('password') }}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="input input-sm w-full"
|
||||
v-model="editForm.password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm">{{ t('label') }} ({{ t('optional') }})</label>
|
||||
<TextInput
|
||||
class="w-full"
|
||||
v-model="editForm.label"
|
||||
:placeholder="t('label')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="handleCancel"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="handleSave"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
<span
|
||||
v-if="isSaving"
|
||||
class="loading loading-spinner loading-xs"
|
||||
></span>
|
||||
{{ isSaving ? t('checking') : t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isBackendAvailable } from '@/api'
|
||||
import DialogWrapper from '@/components/common/DialogWrapper.vue'
|
||||
import TextInput from '@/components/common/TextInput.vue'
|
||||
import { showNotification } from '@/helper/notification'
|
||||
import { getLabelFromBackend } from '@/helper/utils'
|
||||
import { activeBackend, backendList, updateBackend } from '@/store/setup'
|
||||
import type { Backend } from '@/types'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
defaultBackendUuid?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const editForm = ref<Omit<Backend, 'uuid'> | null>(null)
|
||||
const selectedBackendUuid = ref<string>('')
|
||||
const isSaving = ref(false)
|
||||
|
||||
const selectedBackend = computed(() => {
|
||||
return backendList.value.find((b) => b.uuid === selectedBackendUuid.value) || null
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
if (props.defaultBackendUuid) {
|
||||
selectedBackendUuid.value = props.defaultBackendUuid
|
||||
} else if (activeBackend.value) {
|
||||
selectedBackendUuid.value = activeBackend.value.uuid
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
selectedBackend,
|
||||
(backend) => {
|
||||
if (backend) {
|
||||
editForm.value = {
|
||||
protocol: backend.protocol,
|
||||
host: backend.host,
|
||||
port: backend.port,
|
||||
secondaryPath: backend.secondaryPath,
|
||||
password: backend.password,
|
||||
label: backend.label || '',
|
||||
disableUpgradeCore: backend.disableUpgradeCore || false,
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const handleCancel = () => {
|
||||
isVisible.value = false
|
||||
editForm.value = null
|
||||
selectedBackendUuid.value = ''
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editForm.value || !selectedBackend.value) return
|
||||
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
const testBackend: Backend = {
|
||||
uuid: selectedBackend.value.uuid,
|
||||
...editForm.value,
|
||||
}
|
||||
|
||||
const isAvailable = await isBackendAvailable(testBackend, 10000)
|
||||
|
||||
if (!isAvailable) {
|
||||
showNotification({
|
||||
content: t('backendConnectionFailed'),
|
||||
type: 'alert-error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
updateBackend(selectedBackend.value.uuid, editForm.value)
|
||||
showNotification({
|
||||
content: t('backendConfigSaved'),
|
||||
type: 'alert-success',
|
||||
})
|
||||
|
||||
isVisible.value = false
|
||||
editForm.value = null
|
||||
selectedBackendUuid.value = ''
|
||||
emit('saved')
|
||||
} catch (error) {
|
||||
showNotification({
|
||||
content: `${t('saveFailed')}: ${error}`,
|
||||
type: 'alert-error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
189
src/components/settings/GeneralSettings.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<ZashboardSettings />
|
||||
<div
|
||||
v-if="hasVisibleGeneralItems"
|
||||
class="divider my-4"
|
||||
/>
|
||||
<!-- dashboard -->
|
||||
<div
|
||||
v-if="hasVisibleGeneralItems"
|
||||
class="p-4 text-sm"
|
||||
>
|
||||
<div class="settings-title">
|
||||
{{ $t('general') }}
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.autoDisconnectIdleUDP`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('autoDisconnectIdleUDP') }}
|
||||
<QuestionMarkCircleIcon
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@mouseenter="showTip($event, $t('autoDisconnectIdleUDPTip'))"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="autoDisconnectIdleUDP"
|
||||
class="toggle"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
autoDisconnectIdleUDP &&
|
||||
!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.autoDisconnectIdleUDPTime`]
|
||||
"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('autoDisconnectIdleUDPTime') }}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm w-20"
|
||||
v-model="autoDisconnectIdleUDPTime"
|
||||
/>
|
||||
mins
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.IPInfoAPI`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('IPInfoAPI') }}
|
||||
<QuestionMarkCircleIcon
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@mouseenter="showTip($event, $t('IPInfoAPITip'))"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
class="select select-sm min-w-24"
|
||||
v-model="IPInfoAPI"
|
||||
>
|
||||
<option
|
||||
v-for="opt in Object.values(IP_INFO_API)"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.scrollAnimationEffect`]"
|
||||
class="setting-item md:hidden!"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('scrollAnimationEffect') }}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="scrollAnimationEffect"
|
||||
class="toggle"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.swipeInPages`]"
|
||||
class="setting-item md:hidden!"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('swipeInPages') }}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="swipeInPages"
|
||||
class="toggle"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="swipeInPages && !hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.swipeInTabs`]"
|
||||
class="setting-item md:hidden!"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('swipeInTabs') }}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="swipeInTabs"
|
||||
class="toggle"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.disablePullToRefresh`]"
|
||||
class="setting-item md:hidden!"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('disablePullToRefresh') }}
|
||||
<QuestionMarkCircleIcon
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@mouseenter="showTip($event, $t('disablePullToRefreshTip'))"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="disablePullToRefresh"
|
||||
class="toggle"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isSingBox && !hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.displayAllFeatures`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('displayAllFeatures') }}
|
||||
<QuestionMarkCircleIcon
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@mouseenter="showTip($event, $t('displayAllFeaturesTip'))"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="displayAllFeatures"
|
||||
class="toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isSingBox } from '@/api'
|
||||
import { IP_INFO_API, SETTINGS_MENU_KEY } from '@/constant'
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import {
|
||||
autoDisconnectIdleUDP,
|
||||
autoDisconnectIdleUDPTime,
|
||||
disablePullToRefresh,
|
||||
displayAllFeatures,
|
||||
hiddenSettingsItems,
|
||||
IPInfoAPI,
|
||||
scrollAnimationEffect,
|
||||
swipeInPages,
|
||||
swipeInTabs,
|
||||
} from '@/store/settings'
|
||||
import { QuestionMarkCircleIcon } from '@heroicons/vue/24/outline'
|
||||
import { computed } from 'vue'
|
||||
import ZashboardSettings from './ZashboardSettings.vue'
|
||||
|
||||
const { showTip } = useTooltip()
|
||||
|
||||
// 检查"通用"区块是否有可见的子项
|
||||
const hasVisibleGeneralItems = computed(() => {
|
||||
return (
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.autoDisconnectIdleUDP`] ||
|
||||
(autoDisconnectIdleUDP.value &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.autoDisconnectIdleUDPTime`]) ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.IPInfoAPI`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.scrollAnimationEffect`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.swipeInPages`] ||
|
||||
(swipeInPages.value &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.swipeInTabs`]) ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.disablePullToRefresh`] ||
|
||||
(isSingBox.value &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.displayAllFeatures`])
|
||||
)
|
||||
})
|
||||
</script>
|
||||
127
src/components/settings/GroupTestUrlsSettings.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
{{ $t('groupTestUrls') }}
|
||||
<template v-if="groupTestUrls.length"> ({{ groupTestUrls.length }}) </template>
|
||||
<button
|
||||
v-if="groupTestUrls.length"
|
||||
class="btn btn-sm btn-circle"
|
||||
@click="dialogVisible = !dialogVisible"
|
||||
>
|
||||
<ChevronUpIcon
|
||||
v-if="dialogVisible"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<QuestionMarkCircleIcon
|
||||
class="h-4 w-4"
|
||||
@mouseenter="groupTestUrlsTip"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="transparent-collapse collapse rounded-none shadow-none"
|
||||
:class="dialogVisible ? 'collapse-open' : ''"
|
||||
>
|
||||
<div class="collapse-content p-0">
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<template v-if="dialogVisible">
|
||||
<div
|
||||
v-for="groupTestUrl in groupTestUrls"
|
||||
:key="groupTestUrl.uuid"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<TextInput
|
||||
class="w-32"
|
||||
v-model="groupTestUrl.name"
|
||||
:clearable="true"
|
||||
:placeholder="$t('groupName')"
|
||||
/>
|
||||
<ArrowRightCircleIcon class="h-4 w-4 shrink-0" />
|
||||
<TextInput
|
||||
class="max-w-96 flex-1"
|
||||
v-model="groupTestUrl.url"
|
||||
:clearable="true"
|
||||
:placeholder="$t('speedtestUrl')"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-sm btn-circle"
|
||||
@click="removeGroupTestUrl(groupTestUrl.uuid)"
|
||||
>
|
||||
<TrashIcon class="h-4 w-4 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<TextInput
|
||||
class="w-32"
|
||||
v-model="newGroupTestUrl.name"
|
||||
:placeholder="$t('groupName')"
|
||||
:menus="proxyGroupList.filter((group) => !groupTestUrls.some((item) => item.name === group))"
|
||||
@keydown.enter="addGroupTestUrl"
|
||||
/>
|
||||
<ArrowRightCircleIcon class="h-4 w-4 shrink-0" />
|
||||
<TextInput
|
||||
class="max-w-96 flex-1"
|
||||
v-model="newGroupTestUrl.url"
|
||||
:clearable="true"
|
||||
:placeholder="$t('speedtestUrl')"
|
||||
@keydown.enter="addGroupTestUrl"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-sm btn-circle"
|
||||
@click="addGroupTestUrl"
|
||||
>
|
||||
<PlusIcon class="h-4 w-4 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import { proxyGroupList } from '@/store/proxies'
|
||||
import { groupTestUrls } from '@/store/settings'
|
||||
import {
|
||||
ArrowRightCircleIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PlusIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { useSessionStorage } from '@vueuse/core'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TextInput from '../common/TextInput.vue'
|
||||
|
||||
const { showTip } = useTooltip()
|
||||
const { t } = useI18n()
|
||||
|
||||
const dialogVisible = useSessionStorage('cache/group-test-urls-dialog-visible', false)
|
||||
const newGroupTestUrl = reactive({
|
||||
name: '',
|
||||
url: '',
|
||||
})
|
||||
|
||||
const groupTestUrlsTip = (e: Event) => {
|
||||
return showTip(e, t('groupTestUrlsTip'))
|
||||
}
|
||||
|
||||
const addGroupTestUrl = () => {
|
||||
if (!newGroupTestUrl.name || !newGroupTestUrl.url) return
|
||||
dialogVisible.value = true
|
||||
groupTestUrls.value.push({ ...newGroupTestUrl, uuid: uuid() })
|
||||
newGroupTestUrl.name = ''
|
||||
newGroupTestUrl.url = ''
|
||||
}
|
||||
|
||||
const removeGroupTestUrl = (uuid: string) => {
|
||||
groupTestUrls.value = groupTestUrls.value.filter((item) => item.uuid !== uuid)
|
||||
}
|
||||
</script>
|
||||
111
src/components/settings/IconSettings.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
{{ $t('customIcon') }}
|
||||
<template v-if="iconReflectList.length"> ({{ iconReflectList.length }}) </template>
|
||||
<button
|
||||
v-if="iconReflectList.length"
|
||||
class="btn btn-sm btn-circle"
|
||||
@click="dialogVisible = !dialogVisible"
|
||||
>
|
||||
<ChevronUpIcon
|
||||
v-if="dialogVisible"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="transparent-collapse collapse rounded-none shadow-none"
|
||||
:class="dialogVisible ? 'collapse-open' : ''"
|
||||
>
|
||||
<div class="collapse-content p-0">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
<template v-if="dialogVisible">
|
||||
<div
|
||||
v-for="iconReflect in iconReflectList"
|
||||
:key="iconReflect.uuid"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<TextInput
|
||||
class="w-32"
|
||||
v-model="iconReflect.name"
|
||||
:placeholder="$t('groupName')"
|
||||
/>
|
||||
<ArrowRightCircleIcon class="h-4 w-4 shrink-0" />
|
||||
<TextInput
|
||||
v-model="iconReflect.icon"
|
||||
placeholder="Icon URL"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-sm btn-circle"
|
||||
@click="removeIconReflect(iconReflect.uuid)"
|
||||
>
|
||||
<TrashIcon class="h-4 w-4 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<TextInput
|
||||
class="w-32"
|
||||
v-model="newIconReflect.name"
|
||||
placeholder="Name"
|
||||
:menus="
|
||||
proxyGroupList.filter((group) => !iconReflectList.some((item) => item.name === group))
|
||||
"
|
||||
@keydown.enter="addIconReflect"
|
||||
/>
|
||||
<ArrowRightCircleIcon class="h-4 w-4 shrink-0" />
|
||||
<TextInput
|
||||
v-model="newIconReflect.icon"
|
||||
placeholder="Icon URL"
|
||||
@keydown.enter="addIconReflect"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-sm btn-circle"
|
||||
@click="addIconReflect"
|
||||
>
|
||||
<PlusIcon class="h-4 w-4 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { proxyGroupList } from '@/store/proxies'
|
||||
import { iconReflectList } from '@/store/settings'
|
||||
import {
|
||||
ArrowRightCircleIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { useSessionStorage } from '@vueuse/core'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { reactive } from 'vue'
|
||||
import TextInput from '../common/TextInput.vue'
|
||||
|
||||
const dialogVisible = useSessionStorage('cache/icon-dialog-visible', false)
|
||||
const newIconReflect = reactive({
|
||||
name: '',
|
||||
icon: '',
|
||||
})
|
||||
|
||||
const addIconReflect = () => {
|
||||
if (!newIconReflect.name || !newIconReflect.icon) return
|
||||
dialogVisible.value = true
|
||||
iconReflectList.value.push({ ...newIconReflect, uuid: uuid() })
|
||||
newIconReflect.name = ''
|
||||
newIconReflect.icon = ''
|
||||
}
|
||||
|
||||
const removeIconReflect = (uuid: string) => {
|
||||
const index = iconReflectList.value.findIndex((item) => item.uuid === uuid)
|
||||
iconReflectList.value.splice(index, 1)
|
||||
}
|
||||
</script>
|
||||
34
src/components/settings/LanguageSelect.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="setting-item">
|
||||
<div class="setting-item-label">
|
||||
{{ $t('language') }}
|
||||
</div>
|
||||
<select
|
||||
class="select select-sm w-48"
|
||||
v-model="language"
|
||||
@change="() => (locale = language)"
|
||||
>
|
||||
<option
|
||||
v-for="opt in Object.values(LANG)"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ langLabelMap[opt] || opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LANG } from '@/constant'
|
||||
import { language } from '@/store/settings'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const langLabelMap = {
|
||||
[LANG.EN_US]: 'English',
|
||||
[LANG.ZH_CN]: '简体中文',
|
||||
[LANG.ZH_TW]: '繁體中文',
|
||||
[LANG.RU_RU]: 'Русский',
|
||||
}
|
||||
</script>
|
||||
38
src/components/settings/OverviewCard.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<!-- overview -->
|
||||
<div class="flex flex-col gap-2 p-4 text-sm">
|
||||
<div class="flex items-center gap-2 py-2 text-lg font-bold">
|
||||
{{ $t('overview') }}
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<StatisticsStats type="settings" />
|
||||
<template v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.overview}.networkCard`]">
|
||||
<IPCheck />
|
||||
<ConnectionStatus />
|
||||
</template>
|
||||
<SpeedCharts />
|
||||
<MemoryCharts />
|
||||
<ConnectionsCharts />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ConnectionsCharts from '@/components/overview/ConnectionsCharts.vue'
|
||||
import ConnectionStatus from '@/components/overview/ConnectionStatus.vue'
|
||||
import IPCheck from '@/components/overview/IPCheck.vue'
|
||||
import MemoryCharts from '@/components/overview/MemoryCharts.vue'
|
||||
import SpeedCharts from '@/components/overview/SpeedCharts.vue'
|
||||
import StatisticsStats from '@/components/overview/StatisticsStats.vue'
|
||||
import { SETTINGS_MENU_KEY } from '@/constant'
|
||||
import { hiddenSettingsItems } from '@/store/settings'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const isMounted = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(() => {
|
||||
isMounted.value = true
|
||||
})
|
||||
})
|
||||
</script>
|
||||
120
src/components/settings/OverviewSettings.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<!-- overview -->
|
||||
<template
|
||||
v-if="!splitOverviewPage && !hiddenSettingsItems[`${SETTINGS_MENU_KEY.overview}.overviewCard`]"
|
||||
>
|
||||
<OverviewCard />
|
||||
<div class="divider my-4" />
|
||||
</template>
|
||||
<div
|
||||
v-if="hasVisibleItems"
|
||||
class="flex flex-col gap-2 p-4 text-sm"
|
||||
>
|
||||
<div class="settings-title">
|
||||
{{ $t('overviewSettings') }}
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.overview}.splitOverviewPage`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('splitOverviewPage') }}
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="splitOverviewPage"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.overview}.autoIPCheckWhenStart`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('autoIPCheckWhenStart') }}
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="autoIPCheck"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.overview}.autoConnectionCheckWhenStart`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('autoConnectionCheckWhenStart') }}
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="autoConnectionCheck"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!hiddenSettingsItems[`${SETTINGS_MENU_KEY.overview}.showStatisticsWhenSidebarCollapsed`]
|
||||
"
|
||||
class="setting-item max-md:hidden"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('showStatisticsWhenSidebarCollapsed') }}
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="showStatisticsWhenSidebarCollapsed"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.overview}.numberOfChartsInSidebar`]"
|
||||
class="setting-item max-md:hidden"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('numberOfChartsInSidebar') }}
|
||||
</div>
|
||||
<select
|
||||
class="select select-sm min-w-24"
|
||||
v-model="numberOfChartsInSidebar"
|
||||
>
|
||||
<option
|
||||
v-for="opt in [1, 2, 3]"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SETTINGS_MENU_KEY } from '@/constant'
|
||||
import {
|
||||
autoConnectionCheck,
|
||||
autoIPCheck,
|
||||
hiddenSettingsItems,
|
||||
numberOfChartsInSidebar,
|
||||
showStatisticsWhenSidebarCollapsed,
|
||||
splitOverviewPage,
|
||||
} from '@/store/settings'
|
||||
import { computed } from 'vue'
|
||||
import OverviewCard from './OverviewCard.vue'
|
||||
|
||||
// 检查是否有可见的子项
|
||||
const hasVisibleItems = computed(() => {
|
||||
return (
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.overview}.splitOverviewPage`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.overview}.autoIPCheckWhenStart`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.overview}.autoConnectionCheckWhenStart`] ||
|
||||
!hiddenSettingsItems.value[
|
||||
`${SETTINGS_MENU_KEY.overview}.showStatisticsWhenSidebarCollapsed`
|
||||
] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.overview}.numberOfChartsInSidebar`]
|
||||
)
|
||||
})
|
||||
</script>
|
||||
329
src/components/settings/ProxiesSettings.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 p-4 text-sm">
|
||||
<template v-if="hasVisibleLatencyItems">
|
||||
<div class="settings-title">
|
||||
{{ $t('latency') }}
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.speedtestUrl`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('speedtestUrl') }}
|
||||
</div>
|
||||
<TextInput
|
||||
class="flex-2"
|
||||
v-model="speedtestUrl"
|
||||
:clearable="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.speedtestTimeout`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('speedtestTimeout') }}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm w-20"
|
||||
v-model="speedtestTimeout"
|
||||
/>
|
||||
ms
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.lowLatency`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('lowLatencyDesc') }}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm w-20"
|
||||
v-model="lowLatency"
|
||||
/>
|
||||
ms
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.mediumLatency`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('mediumLatencyDesc') }}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm w-20"
|
||||
v-model="mediumLatency"
|
||||
/>
|
||||
ms
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.ipv6Test`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('ipv6Test') }}
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="IPv6test"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.independentLatencyTest`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('independentLatencyTest') }}
|
||||
<QuestionMarkCircleIcon
|
||||
class="h-4 w-4"
|
||||
@mouseenter="independentLatencyTestTip"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="independentLatencyTest"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
independentLatencyTest &&
|
||||
!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.groupTestUrls`]
|
||||
"
|
||||
class="col-span-full"
|
||||
>
|
||||
<GroupTestUrlsSettings />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="hasVisibleProxyStyleItems">
|
||||
<div
|
||||
v-if="hasVisibleLatencyItems"
|
||||
class="divider my-4"
|
||||
></div>
|
||||
<div class="settings-title">
|
||||
{{ $t('proxyStyle') }}
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.twoColumnProxyGroup`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('twoColumnProxyGroup') }}
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="twoColumnProxyGroup"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.truncateProxyName`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('truncateProxyName') }}
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="truncateProxyName"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.displayGlobalByMode`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('displayGlobalByMode') }}
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="displayGlobalByMode"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
displayGlobalByMode &&
|
||||
isSingBox &&
|
||||
!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.customGlobalNode`]
|
||||
"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('customGlobalNode') }}
|
||||
</div>
|
||||
<select
|
||||
class="select select-sm min-w-24"
|
||||
v-model="customGlobalNode"
|
||||
>
|
||||
<option
|
||||
v-for="opt in Object.keys(proxyMap)"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.proxyPreviewType`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('proxyPreviewType') }}
|
||||
</div>
|
||||
<select
|
||||
class="select select-sm min-w-24"
|
||||
v-model="proxyPreviewType"
|
||||
>
|
||||
<option
|
||||
v-for="opt in Object.values(PROXY_PREVIEW_TYPE)"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ $t(opt) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.proxyCardSize`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('proxyCardSize') }}
|
||||
</div>
|
||||
<select
|
||||
class="select select-sm min-w-24"
|
||||
v-model="proxyCardSize"
|
||||
@change="handlerProxyCardSizeChange"
|
||||
>
|
||||
<option
|
||||
v-for="opt in Object.values(PROXY_CARD_SIZE)"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ $t(opt) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.proxyGroupIconSize`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('proxyGroupIconSize') }}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm w-24"
|
||||
v-model="proxyGroupIconSize"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.proxyGroupIconMargin`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('proxyGroupIconMargin') }}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
class="input input-sm w-24"
|
||||
v-model="proxyGroupIconMargin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.proxies}.iconSettings`]">
|
||||
<div
|
||||
v-if="hasVisibleLatencyItems || hasVisibleProxyStyleItems"
|
||||
class="divider my-4"
|
||||
></div>
|
||||
<div class="settings-title">
|
||||
{{ $t('icon') }}
|
||||
</div>
|
||||
<IconSettings />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isSingBox } from '@/api'
|
||||
import { PROXY_CARD_SIZE, PROXY_PREVIEW_TYPE, SETTINGS_MENU_KEY } from '@/constant'
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import { getMinCardWidth } from '@/helper/utils'
|
||||
import { proxyMap } from '@/store/proxies'
|
||||
import {
|
||||
customGlobalNode,
|
||||
displayGlobalByMode,
|
||||
hiddenSettingsItems,
|
||||
independentLatencyTest,
|
||||
IPv6test,
|
||||
lowLatency,
|
||||
mediumLatency,
|
||||
minProxyCardWidth,
|
||||
proxyCardSize,
|
||||
proxyGroupIconMargin,
|
||||
proxyGroupIconSize,
|
||||
proxyPreviewType,
|
||||
speedtestTimeout,
|
||||
speedtestUrl,
|
||||
truncateProxyName,
|
||||
twoColumnProxyGroup,
|
||||
} from '@/store/settings'
|
||||
import { QuestionMarkCircleIcon } from '@heroicons/vue/24/outline'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TextInput from '../common/TextInput.vue'
|
||||
import GroupTestUrlsSettings from './GroupTestUrlsSettings.vue'
|
||||
import IconSettings from './IconSettings.vue'
|
||||
|
||||
const { showTip } = useTooltip()
|
||||
const { t } = useI18n()
|
||||
const independentLatencyTestTip = (e: Event) => {
|
||||
return showTip(e, t('independentLatencyTestTip'))
|
||||
}
|
||||
|
||||
const handlerProxyCardSizeChange = () => {
|
||||
minProxyCardWidth.value = getMinCardWidth(proxyCardSize.value)
|
||||
}
|
||||
|
||||
// 检查"延迟"区块是否有可见的子项
|
||||
const hasVisibleLatencyItems = computed(() => {
|
||||
return (
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.speedtestUrl`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.speedtestTimeout`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.lowLatency`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.mediumLatency`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.ipv6Test`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.independentLatencyTest`] ||
|
||||
(independentLatencyTest.value &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.groupTestUrls`])
|
||||
)
|
||||
})
|
||||
|
||||
// 检查"代理样式"区块是否有可见的子项
|
||||
const hasVisibleProxyStyleItems = computed(() => {
|
||||
return (
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.twoColumnProxyGroup`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.truncateProxyName`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.displayGlobalByMode`] ||
|
||||
(displayGlobalByMode.value &&
|
||||
isSingBox.value &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.customGlobalNode`]) ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.proxyPreviewType`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.proxyCardSize`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.proxyGroupIconSize`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.proxies}.proxyGroupIconMargin`]
|
||||
)
|
||||
})
|
||||
</script>
|
||||
169
src/components/settings/SettingsMenu.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div
|
||||
ref="menuRef"
|
||||
class="settings-menu scrollbar-hidden ctrls-bar p-1 px-2"
|
||||
@touchstart.passive.stop
|
||||
@touchmove.passive.stop
|
||||
@touchend.passive.stop
|
||||
>
|
||||
<div class="relative flex w-full max-w-7xl flex-row">
|
||||
<div
|
||||
class="bg-neutral absolute top-1 left-0 -z-1 h-8 rounded-lg"
|
||||
:class="[!isSwiping ? 'transition-transform duration-300 will-change-transform' : '']"
|
||||
:style="activeStyle"
|
||||
></div>
|
||||
<div
|
||||
v-for="item in menuItems"
|
||||
:key="item.key"
|
||||
ref="menuItemRefs"
|
||||
:data-key="item.key"
|
||||
:id="`menu-item-${item.key}`"
|
||||
class="mr-2 flex h-10 w-full flex-1 flex-shrink-0 cursor-pointer items-center justify-center gap-2 truncate transition-all duration-300"
|
||||
:class="[activeMenuKey === item.key ? 'text-neutral-content' : '']"
|
||||
@click="handleMenuClick(item.key)"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
<span class="hidden text-sm lg:block">
|
||||
{{ $t(item.label) }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-circle btn-sm my-auto"
|
||||
@click="showVisibilityDialog = true"
|
||||
>
|
||||
<Cog6ToothIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<SettingsVisibilityDialog v-model="showVisibilityDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCtrlsBar } from '@/composables/useCtrlsBar'
|
||||
import { SETTINGS_MENU_KEY } from '@/constant'
|
||||
import { Cog6ToothIcon } from '@heroicons/vue/24/outline'
|
||||
import { useElementSize, useSwipe } from '@vueuse/core'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import SettingsVisibilityDialog from './SettingsVisibilityDialog.vue'
|
||||
|
||||
type MenuItem = {
|
||||
key: SETTINGS_MENU_KEY
|
||||
label: string
|
||||
icon: Component
|
||||
component: Component
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
menuItems: MenuItem[]
|
||||
activeMenuKey: SETTINGS_MENU_KEY
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'menu-click', key: SETTINGS_MENU_KEY): void
|
||||
}>()
|
||||
|
||||
const showVisibilityDialog = ref(false)
|
||||
|
||||
const menuRef = ref<HTMLDivElement>()
|
||||
const menuItemRefs = ref<HTMLLIElement[]>([])
|
||||
const { width } = useElementSize(menuRef)
|
||||
const activeLeft = ref(0)
|
||||
const activeWidth = ref(0)
|
||||
const activeStyle = computed(() => {
|
||||
return {
|
||||
transform: `translateX(${activeLeft.value}px)`,
|
||||
width: `${activeWidth.value}px`,
|
||||
}
|
||||
})
|
||||
|
||||
useCtrlsBar()
|
||||
|
||||
const updateActiveMenuLeft = async () => {
|
||||
await nextTick()
|
||||
const itemRef = menuItemRefs.value.find((el) => el.dataset.key === props.activeMenuKey)
|
||||
if (itemRef) {
|
||||
activeLeft.value = itemRef.offsetLeft
|
||||
}
|
||||
}
|
||||
|
||||
const updateActiveMenuWidth = async () => {
|
||||
await nextTick()
|
||||
const itemRef = menuItemRefs.value.find((el) => el.dataset.key === props.activeMenuKey)
|
||||
if (itemRef) {
|
||||
activeWidth.value = itemRef.offsetWidth
|
||||
}
|
||||
}
|
||||
|
||||
const { isSwiping } = useSwipe(menuRef, {
|
||||
passive: false,
|
||||
onSwipe(e: TouchEvent) {
|
||||
if (!menuRef.value) return
|
||||
const menuRect = menuRef.value.getBoundingClientRect()
|
||||
const relativeX = e.touches[0].clientX - menuRect.left
|
||||
|
||||
activeLeft.value = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
relativeX - activeWidth.value / 2,
|
||||
menuRef.value.offsetWidth - activeWidth.value - 16,
|
||||
),
|
||||
)
|
||||
const targetKey = getMenuItemAtPosition(e.touches[0].clientX)
|
||||
if (targetKey && targetKey !== props.activeMenuKey) {
|
||||
emit('menu-click', targetKey)
|
||||
}
|
||||
},
|
||||
onSwipeEnd() {
|
||||
updateActiveMenuLeft()
|
||||
},
|
||||
})
|
||||
|
||||
const handleMenuClick = (key: SETTINGS_MENU_KEY) => {
|
||||
if (isSwiping.value) return
|
||||
emit('menu-click', key)
|
||||
}
|
||||
|
||||
const getMenuItemAtPosition = (x: number): SETTINGS_MENU_KEY | null => {
|
||||
if (!menuRef.value) return null
|
||||
|
||||
const menuRect = menuRef.value.getBoundingClientRect()
|
||||
const relativeX = x - menuRect.left
|
||||
|
||||
// 找到触摸位置对应的菜单项
|
||||
for (const itemEl of menuItemRefs.value) {
|
||||
const itemRect = itemEl.getBoundingClientRect()
|
||||
const itemRelativeX = itemRect.left - menuRect.left
|
||||
const itemWidth = itemRect.width
|
||||
|
||||
if (relativeX >= itemRelativeX && relativeX <= itemRelativeX + itemWidth) {
|
||||
return itemEl.dataset.key as SETTINGS_MENU_KEY
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.activeMenuKey,
|
||||
() => {
|
||||
if (isSwiping.value) return
|
||||
updateActiveMenuLeft()
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [width.value, props.menuItems],
|
||||
() => {
|
||||
updateActiveMenuWidth()
|
||||
updateActiveMenuLeft()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
367
src/components/settings/SettingsVisibilityDialog.vue
Normal file
@@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<DialogWrapper
|
||||
v-model="isOpen"
|
||||
:title="$t('settingsVisibility')"
|
||||
>
|
||||
<div class="flex flex-col text-sm">
|
||||
<div class="mb-4 flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="applyShowAllPreset"
|
||||
>
|
||||
{{ $t('showAllPreset') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
@click="applyMinimalPreset"
|
||||
>
|
||||
{{ $t('minimalPreset') }}
|
||||
</button>
|
||||
</div>
|
||||
<Draggable
|
||||
v-model="orderedCategories"
|
||||
:animation="150"
|
||||
ghost-class="ghost"
|
||||
handle=".drag-handle"
|
||||
:item-key="(item: Category) => item.key"
|
||||
>
|
||||
<template #item="{ element: category }">
|
||||
<div
|
||||
class="collapse-arrow bg-base-200/50 collapse mb-4"
|
||||
:class="expandedCategories[category.key] ? 'collapse-open' : 'collapse-close'"
|
||||
>
|
||||
<div
|
||||
class="collapse-title cursor-pointer font-medium"
|
||||
@click="expandedCategories[category.key] = !expandedCategories[category.key]"
|
||||
>
|
||||
<div class="setting-item">
|
||||
<Bars3Icon class="drag-handle text-base-content/50 h-5 w-5 cursor-move" />
|
||||
<div class="setting-item-label">
|
||||
{{ $t(category.label) }}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
:checked="!hiddenSettingsItems[category.key]"
|
||||
@click.stop
|
||||
@change="
|
||||
hiddenSettingsItems[category.key] = !($event.target as HTMLInputElement).checked
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content p-0">
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="item in category.items"
|
||||
:key="item.key"
|
||||
class="setting-item px-4"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t(item.label) }}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
:checked="!hiddenSettingsItems[item.key]"
|
||||
@change="
|
||||
hiddenSettingsItems[item.key] = !($event.target as HTMLInputElement).checked
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DialogWrapper from '@/components/common/DialogWrapper.vue'
|
||||
import { SETTINGS_MENU_KEY } from '@/constant'
|
||||
import { hiddenSettingsItems, settingsMenuOrder } from '@/store/settings'
|
||||
import { Bars3Icon } from '@heroicons/vue/24/outline'
|
||||
import { computed, ref } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
const isOpen = defineModel<boolean>({ required: true })
|
||||
|
||||
const expandedCategories = ref<Record<string, boolean>>({})
|
||||
|
||||
type CategoryItem = {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type Category = {
|
||||
key: SETTINGS_MENU_KEY
|
||||
label: string
|
||||
items: CategoryItem[]
|
||||
}
|
||||
|
||||
const allCategories: Category[] = [
|
||||
{
|
||||
key: SETTINGS_MENU_KEY.general,
|
||||
label: 'zashboardSettings',
|
||||
items: [
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.zashboardSettings.language`,
|
||||
label: 'language',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.zashboardSettings.fonts`,
|
||||
label: 'fonts',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.zashboardSettings.emoji`,
|
||||
label: 'emoji',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.zashboardSettings.customBackgroundURL`,
|
||||
label: 'customBackgroundURL',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.zashboardSettings.transparent`,
|
||||
label: 'transparent',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.zashboardSettings.blurIntensity`,
|
||||
label: 'blurIntensity',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.zashboardSettings.defaultTheme`,
|
||||
label: 'defaultTheme',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.zashboardSettings.darkTheme`,
|
||||
label: 'darkTheme',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.zashboardSettings.autoSwitchTheme`,
|
||||
label: 'autoSwitchTheme',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.zashboardSettings.autoUpgrade`,
|
||||
label: 'autoUpgrade',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.zashboardSettings.upgradeUI`,
|
||||
label: 'upgradeUI',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.zashboardSettings.exportSettings`,
|
||||
label: 'exportSettings',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.zashboardSettings.importSettings`,
|
||||
label: 'importSettings',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.autoDisconnectIdleUDP`,
|
||||
label: 'autoDisconnectIdleUDP',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.autoDisconnectIdleUDPTime`,
|
||||
label: 'autoDisconnectIdleUDPTime',
|
||||
},
|
||||
{ key: `${SETTINGS_MENU_KEY.general}.IPInfoAPI`, label: 'IPInfoAPI' },
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.scrollAnimationEffect`,
|
||||
label: 'scrollAnimationEffect',
|
||||
},
|
||||
{ key: `${SETTINGS_MENU_KEY.general}.swipeInPages`, label: 'swipeInPages' },
|
||||
{ key: `${SETTINGS_MENU_KEY.general}.swipeInTabs`, label: 'swipeInTabs' },
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.disablePullToRefresh`,
|
||||
label: 'disablePullToRefresh',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.general}.displayAllFeatures`,
|
||||
label: 'displayAllFeatures',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_MENU_KEY.overview,
|
||||
label: 'overviewSettings',
|
||||
items: [
|
||||
{ key: `${SETTINGS_MENU_KEY.overview}.overviewCard`, label: 'chartsCard' },
|
||||
{ key: `${SETTINGS_MENU_KEY.overview}.networkCard`, label: 'networkCard' },
|
||||
{ key: `${SETTINGS_MENU_KEY.overview}.splitOverviewPage`, label: 'splitOverviewPage' },
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.overview}.autoIPCheckWhenStart`,
|
||||
label: 'autoIPCheckWhenStart',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.overview}.autoConnectionCheckWhenStart`,
|
||||
label: 'autoConnectionCheckWhenStart',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.overview}.showStatisticsWhenSidebarCollapsed`,
|
||||
label: 'showStatisticsWhenSidebarCollapsed',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.overview}.numberOfChartsInSidebar`,
|
||||
label: 'numberOfChartsInSidebar',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_MENU_KEY.backend,
|
||||
label: 'backendSettings',
|
||||
items: [
|
||||
{ key: `${SETTINGS_MENU_KEY.backend}.backendSwitch`, label: 'backend' },
|
||||
{ key: `${SETTINGS_MENU_KEY.backend}.ports`, label: 'ports' },
|
||||
{ key: `${SETTINGS_MENU_KEY.backend}.tunMode`, label: 'tunMode' },
|
||||
{ key: `${SETTINGS_MENU_KEY.backend}.allowLan`, label: 'allowLan' },
|
||||
{ key: `${SETTINGS_MENU_KEY.backend}.checkUpgrade`, label: 'checkUpgrade' },
|
||||
{ key: `${SETTINGS_MENU_KEY.backend}.autoUpgrade`, label: 'autoUpgrade' },
|
||||
{ key: `${SETTINGS_MENU_KEY.backend}.actions`, label: 'actions' },
|
||||
{ key: `${SETTINGS_MENU_KEY.backend}.dnsQuery`, label: 'DNSQuery' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_MENU_KEY.proxies,
|
||||
label: 'proxySettings',
|
||||
items: [
|
||||
{ key: `${SETTINGS_MENU_KEY.proxies}.speedtestUrl`, label: 'speedtestUrl' },
|
||||
{ key: `${SETTINGS_MENU_KEY.proxies}.speedtestTimeout`, label: 'speedtestTimeout' },
|
||||
{ key: `${SETTINGS_MENU_KEY.proxies}.lowLatency`, label: 'lowLatencyDesc' },
|
||||
{ key: `${SETTINGS_MENU_KEY.proxies}.mediumLatency`, label: 'mediumLatencyDesc' },
|
||||
{ key: `${SETTINGS_MENU_KEY.proxies}.ipv6Test`, label: 'ipv6Test' },
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.proxies}.independentLatencyTest`,
|
||||
label: 'independentLatencyTest',
|
||||
},
|
||||
{ key: `${SETTINGS_MENU_KEY.proxies}.groupTestUrls`, label: 'groupTestUrls' },
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.proxies}.twoColumnProxyGroup`,
|
||||
label: 'twoColumnProxyGroup',
|
||||
},
|
||||
{ key: `${SETTINGS_MENU_KEY.proxies}.truncateProxyName`, label: 'truncateProxyName' },
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.proxies}.displayGlobalByMode`,
|
||||
label: 'displayGlobalByMode',
|
||||
},
|
||||
{ key: `${SETTINGS_MENU_KEY.proxies}.customGlobalNode`, label: 'customGlobalNode' },
|
||||
{ key: `${SETTINGS_MENU_KEY.proxies}.proxyPreviewType`, label: 'proxyPreviewType' },
|
||||
{ key: `${SETTINGS_MENU_KEY.proxies}.proxyCardSize`, label: 'proxyCardSize' },
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.proxies}.proxyGroupIconSize`,
|
||||
label: 'proxyGroupIconSize',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.proxies}.proxyGroupIconMargin`,
|
||||
label: 'proxyGroupIconMargin',
|
||||
},
|
||||
{ key: `${SETTINGS_MENU_KEY.proxies}.iconSettings`, label: 'icon' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_MENU_KEY.connections,
|
||||
label: 'connectionSettings',
|
||||
items: [
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.connections}.connectionStyle`,
|
||||
label: 'connectionStyle',
|
||||
},
|
||||
{
|
||||
key: `${SETTINGS_MENU_KEY.connections}.proxyChainDirection`,
|
||||
label: 'proxyChainDirection',
|
||||
},
|
||||
{ key: `${SETTINGS_MENU_KEY.connections}.tableWidthMode`, label: 'tableWidthMode' },
|
||||
{ key: `${SETTINGS_MENU_KEY.connections}.tableSize`, label: 'tableSize' },
|
||||
{ key: `${SETTINGS_MENU_KEY.connections}.sourceIPLabels`, label: 'sourceIPLabels' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const orderedCategories = computed({
|
||||
get: () => {
|
||||
// 根据 settingsMenuOrder 排序
|
||||
const orderMap = new Map(settingsMenuOrder.value.map((key, index) => [key, index]))
|
||||
return [...allCategories].sort((a, b) => {
|
||||
const orderA = orderMap.get(a.key) ?? Infinity
|
||||
const orderB = orderMap.get(b.key) ?? Infinity
|
||||
return orderA - orderB
|
||||
})
|
||||
},
|
||||
set: (newOrder: Category[]) => {
|
||||
// 更新 settingsMenuOrder
|
||||
settingsMenuOrder.value = newOrder.map((category) => category.key)
|
||||
},
|
||||
})
|
||||
|
||||
// 获取所有设置项的 key(包括分类和子项)
|
||||
const getAllSettingKeys = (): string[] => {
|
||||
const keys: string[] = []
|
||||
for (const category of allCategories) {
|
||||
keys.push(category.key)
|
||||
for (const item of category.items) {
|
||||
keys.push(item.key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// 应用"全部显示"预设
|
||||
const applyShowAllPreset = () => {
|
||||
hiddenSettingsItems.value = {}
|
||||
}
|
||||
|
||||
// 应用"精简显示"预设
|
||||
const applyMinimalPreset = () => {
|
||||
const allKeys = getAllSettingKeys()
|
||||
const minimalHiddenKeys: string[] = [SETTINGS_MENU_KEY.proxies, SETTINGS_MENU_KEY.connections]
|
||||
|
||||
// 隐藏不常用/高级设置项
|
||||
for (const key of allKeys) {
|
||||
if (key.includes('emoji') || key.includes('language')) {
|
||||
minimalHiddenKeys.push(key)
|
||||
}
|
||||
// UDP 相关设置
|
||||
else if (key.includes('autoDisconnectIdleUDP') || key.includes('autoDisconnectIdleUDPTime')) {
|
||||
minimalHiddenKeys.push(key)
|
||||
}
|
||||
// 滚动动画效果、滑动切换相关
|
||||
else if (
|
||||
key.includes('scrollAnimationEffect') ||
|
||||
key.includes('swipeInPages') ||
|
||||
key.includes('swipeInTabs') ||
|
||||
key.includes('disablePullToRefresh')
|
||||
) {
|
||||
minimalHiddenKeys.push(key)
|
||||
}
|
||||
// 其他不常用选项
|
||||
else if (
|
||||
key.includes('displayAllFeatures') ||
|
||||
key.includes('IPInfoAPI') ||
|
||||
key.includes('numberOfChartsInSidebar') ||
|
||||
key.includes('proxyGroupIconSize') ||
|
||||
key.includes('proxyGroupIconMargin') ||
|
||||
key.includes('proxyPreviewType') ||
|
||||
key.includes('proxyCardSize') ||
|
||||
key.includes('twoColumnProxyGroup')
|
||||
) {
|
||||
minimalHiddenKeys.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置隐藏项
|
||||
const newHiddenItems: Record<string, boolean> = {}
|
||||
for (const key of minimalHiddenKeys) {
|
||||
newHiddenItems[key] = true
|
||||
}
|
||||
hiddenSettingsItems.value = newHiddenItems
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
140
src/components/settings/SourceIPInput.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex w-full items-center gap-2"
|
||||
:class="sourceIPLabel.scope?.length ? 'pt-4' : ''"
|
||||
>
|
||||
<slot name="prefix"></slot>
|
||||
<span
|
||||
class="absolute top-0 left-6 truncate text-xs"
|
||||
@mouseenter="checkTruncation"
|
||||
>
|
||||
{{
|
||||
backendList
|
||||
.filter((b) => sourceIPLabel.scope?.includes(b.uuid))
|
||||
.map(getLabelFromBackend)
|
||||
.join(', ')
|
||||
}}
|
||||
</span>
|
||||
<TextInput
|
||||
class="w-12 max-w-64 flex-1 sm:w-36"
|
||||
:menus="sourceList"
|
||||
v-model="sourceIPLabel.key"
|
||||
placeholder="IP | eui64 | /Regex"
|
||||
/>
|
||||
<div
|
||||
v-if="backendList.length > 1"
|
||||
class="rounded-field bg-base-200 flex h-8 w-8 cursor-pointer items-center justify-center"
|
||||
@click="bindBackendMenu"
|
||||
>
|
||||
<LockClosedIcon
|
||||
v-if="isLocked"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<LockOpenIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<ArrowRightCircleIcon class="h-4 w-4 shrink-0" />
|
||||
<TextInput
|
||||
class="w-24 sm:w-40"
|
||||
v-model="sourceIPLabel.label"
|
||||
:placeholder="$t('label')"
|
||||
/>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { checkTruncation, useTooltip } from '@/helper/tooltip'
|
||||
import { getLabelFromBackend } from '@/helper/utils'
|
||||
import { connections } from '@/store/connections'
|
||||
import { sourceIPLabelList } from '@/store/settings'
|
||||
import { backendList } from '@/store/setup'
|
||||
import type { SourceIPLabel } from '@/types'
|
||||
import { ArrowRightCircleIcon, LockClosedIcon, LockOpenIcon } from '@heroicons/vue/24/outline'
|
||||
import { uniq } from 'lodash'
|
||||
import { computed } from 'vue'
|
||||
import TextInput from '../common/TextInput.vue'
|
||||
|
||||
const sourceIPLabel = defineModel<Partial<SourceIPLabel>>({
|
||||
default: {
|
||||
key: '',
|
||||
label: '',
|
||||
},
|
||||
})
|
||||
const sourceList = computed(() => {
|
||||
return uniq(connections.value.map((conn) => conn.metadata.sourceIP))
|
||||
.filter(Boolean)
|
||||
.filter((ip) => !sourceIPLabelList.value.find((item) => item.key === ip))
|
||||
.sort()
|
||||
})
|
||||
|
||||
const getScopeValueFromSouceIPByBackendID = (
|
||||
backendID: string,
|
||||
sourceIP: Partial<SourceIPLabel>,
|
||||
) => {
|
||||
return sourceIP.scope?.some((item) => item === backendID) ?? false
|
||||
}
|
||||
|
||||
const setScopeValueFromSouceIPByBackendID = (
|
||||
backendID: string,
|
||||
sourceIP: Partial<SourceIPLabel>,
|
||||
value: boolean,
|
||||
) => {
|
||||
if (value) {
|
||||
if (!sourceIP.scope) {
|
||||
sourceIP.scope = []
|
||||
}
|
||||
sourceIP.scope?.push(backendID)
|
||||
} else {
|
||||
sourceIP.scope = sourceIP.scope?.filter((item) => item !== backendID)
|
||||
if (!sourceIP.scope?.length) {
|
||||
delete sourceIP.scope
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isLocked = computed(() => {
|
||||
return (
|
||||
sourceIPLabel.value.scope?.length && sourceIPLabel.value.scope.length < backendList.value.length
|
||||
)
|
||||
})
|
||||
|
||||
const { showTip } = useTooltip()
|
||||
const bindBackendMenu = (e: Event) => {
|
||||
const backendListContent = document.createElement('div')
|
||||
|
||||
backendListContent.classList.add('flex', 'flex-col', 'gap-2', 'py-1')
|
||||
|
||||
for (const backend of backendList.value) {
|
||||
const label = document.createElement('label')
|
||||
const checkbox = document.createElement('input')
|
||||
const span = document.createElement('span')
|
||||
|
||||
label.classList.add('flex', 'items-center', 'gap-2', 'cursor-pointer')
|
||||
|
||||
checkbox.type = 'checkbox'
|
||||
checkbox.classList.add('checkbox', 'checkbox-sm')
|
||||
checkbox.checked = getScopeValueFromSouceIPByBackendID(backend.uuid, sourceIPLabel.value)
|
||||
checkbox.addEventListener('change', (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
|
||||
setScopeValueFromSouceIPByBackendID(backend.uuid, sourceIPLabel.value, target.checked)
|
||||
})
|
||||
|
||||
span.textContent = getLabelFromBackend(backend)
|
||||
label.append(checkbox, span)
|
||||
backendListContent.append(label)
|
||||
}
|
||||
|
||||
showTip(e, backendListContent, {
|
||||
theme: 'base',
|
||||
placement: 'bottom-start',
|
||||
trigger: 'click',
|
||||
appendTo: document.body,
|
||||
interactive: true,
|
||||
arrow: false,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
133
src/components/settings/SourceIPLabels.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
{{ $t('sourceIPLabels') }}
|
||||
<template v-if="sourceIPLabelList.length"> ({{ sourceIPLabelList.length }}) </template>
|
||||
<button
|
||||
v-if="sourceIPLabelList.length"
|
||||
class="btn btn-sm btn-circle"
|
||||
@click="dialogVisible = !dialogVisible"
|
||||
>
|
||||
<ChevronUpIcon
|
||||
v-if="dialogVisible"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="transparent-collapse collapse rounded-none shadow-none"
|
||||
:class="dialogVisible ? 'collapse-open' : ''"
|
||||
>
|
||||
<div class="collapse-content p-0">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Draggable
|
||||
v-if="dialogVisible"
|
||||
class="flex flex-1 flex-col gap-2"
|
||||
v-model="sourceIPLabelList"
|
||||
group="list"
|
||||
:animation="150"
|
||||
:handle="'.drag-handle'"
|
||||
:item-key="'uuid'"
|
||||
@start="disableSwipe = true"
|
||||
@end="disableSwipe = false"
|
||||
>
|
||||
<template #item="{ element: sourceIP }">
|
||||
<SourceIPInput
|
||||
:model-value="sourceIP"
|
||||
@update:model-value="handlerLabelUpdate"
|
||||
>
|
||||
<template #prefix>
|
||||
<ChevronUpDownIcon class="drag-handle h-4 w-4 shrink-0 cursor-grab" />
|
||||
</template>
|
||||
<template #default>
|
||||
<button
|
||||
class="btn btn-circle btn-ghost btn-sm"
|
||||
@click="() => handlerLabelRemove(sourceIP.id)"
|
||||
>
|
||||
<TrashIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</template>
|
||||
</SourceIPInput>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SourceIPInput
|
||||
v-model="newLabelForIP"
|
||||
@keydown.enter="handlerLabelAdd"
|
||||
>
|
||||
<template #prefix>
|
||||
<TagIcon class="h-4 w-4 shrink-0" />
|
||||
</template>
|
||||
<template #default>
|
||||
<button
|
||||
class="btn btn-circle btn-sm"
|
||||
@click="handlerLabelAdd"
|
||||
>
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</template>
|
||||
</SourceIPInput>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { disableSwipe } from '@/composables/swipe'
|
||||
import { sourceIPLabelList } from '@/store/settings'
|
||||
import type { SourceIPLabel } from '@/types'
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpDownIcon,
|
||||
ChevronUpIcon,
|
||||
PlusIcon,
|
||||
TagIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { useSessionStorage } from '@vueuse/core'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { ref } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import SourceIPInput from './SourceIPInput.vue'
|
||||
|
||||
const dialogVisible = useSessionStorage('cache/sourceip-label-dialog-visible', false)
|
||||
const newLabelForIP = ref<Omit<SourceIPLabel, 'id'>>({
|
||||
key: '',
|
||||
label: '',
|
||||
})
|
||||
|
||||
const handlerLabelAdd = () => {
|
||||
if (!newLabelForIP.value.key || !newLabelForIP.value.label) {
|
||||
return
|
||||
}
|
||||
|
||||
dialogVisible.value = true
|
||||
sourceIPLabelList.value.push({
|
||||
...newLabelForIP.value,
|
||||
id: uuid(),
|
||||
})
|
||||
|
||||
newLabelForIP.value = {
|
||||
key: '',
|
||||
label: '',
|
||||
}
|
||||
}
|
||||
|
||||
const handlerLabelRemove = (id: string) => {
|
||||
sourceIPLabelList.value.splice(
|
||||
sourceIPLabelList.value.findIndex((item) => item.id === id),
|
||||
1,
|
||||
)
|
||||
}
|
||||
|
||||
const handlerLabelUpdate = (sourceIP: Partial<SourceIPLabel>) => {
|
||||
const index = sourceIPLabelList.value.findIndex((item) => item.id === sourceIP.id)
|
||||
|
||||
sourceIPLabelList.value[index] = {
|
||||
...sourceIPLabelList.value[index],
|
||||
...sourceIP,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
54
src/components/settings/TableSettings.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="shrink-0">{{ $t('showFullProxyChain') }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
v-model="showFullProxyChain"
|
||||
/>
|
||||
</div>
|
||||
<div>{{ $t('customTableColumns') }}</div>
|
||||
<div class="flex gap-4 rounded-sm">
|
||||
<Draggable
|
||||
class="bg-base-200 flex flex-1 flex-col gap-2 p-2"
|
||||
v-model="connectionTableColumns"
|
||||
group="list"
|
||||
:animation="150"
|
||||
:item-key="(id: string) => id"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<button class="btn btn-sm bg-base-100 cursor-move shadow-sm">
|
||||
{{ $t(element) }}
|
||||
</button>
|
||||
</template>
|
||||
</Draggable>
|
||||
<Draggable
|
||||
class="flex flex-1 flex-col gap-2 p-2"
|
||||
v-model="restOfColumns"
|
||||
group="list"
|
||||
:animation="150"
|
||||
:item-key="(id: string) => id"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<button class="btn btn-sm cursor-move">
|
||||
{{ $t(element) }}
|
||||
</button>
|
||||
</template>
|
||||
</Draggable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CONNECTIONS_TABLE_ACCESSOR_KEY } from '@/constant'
|
||||
import { connectionTableColumns, showFullProxyChain } from '@/store/settings'
|
||||
import { ref } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
const restOfColumns = ref(
|
||||
Object.values(CONNECTIONS_TABLE_ACCESSOR_KEY).filter(
|
||||
(key) => !connectionTableColumns.value.includes(key),
|
||||
),
|
||||
)
|
||||
</script>
|
||||
69
src/components/settings/ThemeSelector.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div
|
||||
class="join-item input input-sm inline w-48 p-0"
|
||||
@click="handlerDropdown"
|
||||
>
|
||||
<div class="flex h-full w-full cursor-pointer items-center indent-4">
|
||||
{{ theme }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ALL_THEME } from '@/constant'
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import { customThemes } from '@/store/settings'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const theme = defineModel<string>('value', {
|
||||
type: String,
|
||||
required: true,
|
||||
})
|
||||
|
||||
const themes = computed(() => {
|
||||
if (customThemes.value.length) {
|
||||
return [...ALL_THEME, ...customThemes.value.map((theme) => theme.name)]
|
||||
}
|
||||
|
||||
return ALL_THEME
|
||||
})
|
||||
|
||||
const { showTip, hideTip } = useTooltip()
|
||||
const handlerDropdown = (e: Event) => {
|
||||
const themeCotainer = document.createElement('div')
|
||||
|
||||
themeCotainer.className = 'card h-96 w-48 overflow-y-auto overscroll-contain shadow-2xl'
|
||||
|
||||
for (const themeName of themes.value) {
|
||||
const item = document.createElement('div')
|
||||
const primary = document.createElement('div')
|
||||
const label = document.createElement('span')
|
||||
|
||||
item.dataset.theme = themeName
|
||||
|
||||
item.className = 'flex cursor-pointer items-center gap-2 p-2 bg-base-100 hover:bg-base-200'
|
||||
primary.className = 'h-3 w-5 shadow rounded-field bg-primary'
|
||||
|
||||
label.textContent = themeName
|
||||
|
||||
item.append(primary)
|
||||
item.append(label)
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
theme.value = themeName
|
||||
hideTip()
|
||||
})
|
||||
|
||||
themeCotainer.append(item)
|
||||
}
|
||||
|
||||
showTip(e, themeCotainer, {
|
||||
theme: 'transparent',
|
||||
placement: 'bottom-start',
|
||||
trigger: 'click',
|
||||
appendTo: document.body,
|
||||
interactive: true,
|
||||
arrow: false,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
79
src/components/settings/UpgradeCoreModal.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<DialogWrapper
|
||||
v-model="modalValue"
|
||||
:title="$t('upgradeCore')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-2">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="isCoreUpgrading && upgradingType !== 'auto'"
|
||||
@click="handlerClickUpgradeCore('auto')"
|
||||
>
|
||||
<span
|
||||
v-if="isCoreUpgrading && upgradingType === 'auto'"
|
||||
class="loading loading-spinner loading-md"
|
||||
></span>
|
||||
{{ $t('upgradeCore') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="isCoreUpgrading && upgradingType !== 'release'"
|
||||
@click="handlerClickUpgradeCore('release')"
|
||||
>
|
||||
<span
|
||||
v-if="isCoreUpgrading && upgradingType === 'release'"
|
||||
class="loading loading-spinner loading-md"
|
||||
></span>
|
||||
|
||||
{{ $t('upgradeToRelease') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="isCoreUpgrading && upgradingType !== 'alpha'"
|
||||
@click="handlerClickUpgradeCore('alpha')"
|
||||
>
|
||||
<span
|
||||
v-if="isCoreUpgrading && upgradingType === 'alpha'"
|
||||
class="loading loading-spinner loading-md"
|
||||
></span>
|
||||
{{ $t('upgradeToAlpha') }}
|
||||
</button>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { upgradeCoreAPI } from '@/api'
|
||||
import { handlerUpgradeSuccess } from '@/helper'
|
||||
import { fetchConfigs } from '@/store/config'
|
||||
import { fetchProxies } from '@/store/proxies'
|
||||
import { fetchRules } from '@/store/rules'
|
||||
import { ref } from 'vue'
|
||||
import DialogWrapper from '../common/DialogWrapper.vue'
|
||||
|
||||
const reloadAll = () => {
|
||||
fetchConfigs()
|
||||
fetchRules()
|
||||
fetchProxies()
|
||||
}
|
||||
|
||||
const upgradingType = ref<'release' | 'alpha' | 'auto'>('auto')
|
||||
const modalValue = defineModel<boolean>()
|
||||
const isCoreUpgrading = ref(false)
|
||||
const handlerClickUpgradeCore = async (type: 'release' | 'alpha' | 'auto') => {
|
||||
if (isCoreUpgrading.value) return
|
||||
|
||||
upgradingType.value = type
|
||||
isCoreUpgrading.value = true
|
||||
try {
|
||||
await upgradeCoreAPI(type)
|
||||
reloadAll()
|
||||
modalValue.value = false
|
||||
handlerUpgradeSuccess()
|
||||
isCoreUpgrading.value = false
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
isCoreUpgrading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
389
src/components/settings/ZashboardSettings.vue
Normal file
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<!-- dashboard -->
|
||||
<div
|
||||
v-if="hasVisibleItems"
|
||||
class="relative flex flex-col gap-2 p-4 text-sm"
|
||||
>
|
||||
<div class="settings-title">
|
||||
<div class="indicator">
|
||||
<span
|
||||
v-if="isUIUpdateAvailable"
|
||||
class="indicator-item top-1 -right-1 flex"
|
||||
>
|
||||
<span class="bg-secondary absolute h-2 w-2 animate-ping rounded-full"></span>
|
||||
<span class="bg-secondary h-2 w-2 rounded-full"></span>
|
||||
</span>
|
||||
<a
|
||||
href="https://github.com/Zephyruso/zashboard"
|
||||
target="_blank"
|
||||
>
|
||||
<span> zashboard </span>
|
||||
<span class="text-sm font-normal">
|
||||
{{ zashboardVersion }}
|
||||
<span
|
||||
v-if="commitId"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ commitId }}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm absolute top-2 right-2"
|
||||
@click="refreshPages"
|
||||
v-if="isPWA"
|
||||
>
|
||||
{{ $t('refresh') }}
|
||||
<ArrowPathIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<LanguageSelect
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.language`]"
|
||||
/>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.fonts`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('fonts') }}
|
||||
</div>
|
||||
<select
|
||||
class="select select-sm w-48"
|
||||
v-model="font"
|
||||
>
|
||||
<option
|
||||
v-for="opt in fontOptions"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.emoji`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">Emoji</div>
|
||||
<select
|
||||
class="select select-sm w-48"
|
||||
v-model="emoji"
|
||||
>
|
||||
<option
|
||||
v-for="opt in Object.values(EMOJIS)"
|
||||
:key="opt"
|
||||
:value="opt"
|
||||
>
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.customBackgroundURL`]
|
||||
"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('customBackgroundURL') }}
|
||||
</div>
|
||||
<div class="join">
|
||||
<TextInput
|
||||
class="join-item w-38"
|
||||
v-model="customBackgroundURL"
|
||||
:clearable="true"
|
||||
@update:modelValue="handlerBackgroundURLChange"
|
||||
/>
|
||||
<button
|
||||
class="btn join-item btn-sm"
|
||||
@click="handlerClickUpload"
|
||||
>
|
||||
<ArrowUpTrayIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-circle join-item btn-sm"
|
||||
v-if="customBackgroundURL"
|
||||
@click="displayBgProperty = !displayBgProperty"
|
||||
>
|
||||
<AdjustmentsHorizontalIcon class="h-4 w-4" />
|
||||
</button>
|
||||
<input
|
||||
ref="inputFileRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handlerFileChange"
|
||||
/>
|
||||
</div>
|
||||
<template
|
||||
v-if="
|
||||
customBackgroundURL &&
|
||||
displayBgProperty &&
|
||||
!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.transparent`]
|
||||
"
|
||||
>
|
||||
<div class="setting-item">
|
||||
<div class="setting-item-label">
|
||||
{{ $t('transparent') }}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
v-model="dashboardTransparent"
|
||||
class="range max-w-64"
|
||||
@touchstart.passive.stop
|
||||
@touchmove.passive.stop
|
||||
@touchend.passive.stop
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
customBackgroundURL &&
|
||||
displayBgProperty &&
|
||||
!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.blurIntensity`]
|
||||
"
|
||||
>
|
||||
<div class="setting-item">
|
||||
<div class="setting-item-label">
|
||||
{{ $t('blurIntensity') }}
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="40"
|
||||
v-model="blurIntensity"
|
||||
class="range max-w-64"
|
||||
@touchstart.stop
|
||||
@touchmove.stop
|
||||
@touchend.stop
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.defaultTheme`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('defaultTheme') }}
|
||||
</div>
|
||||
<div class="join">
|
||||
<ThemeSelector
|
||||
class="w-38!"
|
||||
v-model:value="defaultTheme"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-sm join-item"
|
||||
@click="customThemeModal = !customThemeModal"
|
||||
>
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<CustomTheme v-model:value="customThemeModal" />
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
autoTheme &&
|
||||
!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.darkTheme`]
|
||||
"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('darkTheme') }}
|
||||
</div>
|
||||
<ThemeSelector v-model:value="darkTheme" />
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.autoSwitchTheme`]
|
||||
"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('autoSwitchTheme') }}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="autoTheme"
|
||||
class="toggle"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.autoUpgrade`]"
|
||||
class="setting-item"
|
||||
>
|
||||
<div class="setting-item-label">
|
||||
{{ $t('autoUpgrade') }}
|
||||
</div>
|
||||
<input
|
||||
class="toggle"
|
||||
type="checkbox"
|
||||
v-model="autoUpgrade"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.upgradeUI`] ||
|
||||
!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.exportSettings`] ||
|
||||
!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.importSettings`]
|
||||
"
|
||||
class="mt-4 grid max-w-3xl grid-cols-2 gap-2 gap-y-3 md:grid-cols-4"
|
||||
>
|
||||
<button
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.upgradeUI`]"
|
||||
:class="twMerge('btn btn-primary btn-sm', isUIUpgrading ? 'animate-pulse' : '')"
|
||||
@click="handlerClickUpgradeUI"
|
||||
>
|
||||
{{ $t('upgradeUI') }}
|
||||
</button>
|
||||
<div
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.upgradeUI`]"
|
||||
class="sm:hidden"
|
||||
></div>
|
||||
|
||||
<button
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.exportSettings`]"
|
||||
class="btn btn-sm"
|
||||
@click="exportSettings"
|
||||
>
|
||||
{{ $t('exportSettings') }}
|
||||
</button>
|
||||
<ImportSettings
|
||||
v-if="!hiddenSettingsItems[`${SETTINGS_MENU_KEY.general}.zashboardSettings.importSettings`]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { upgradeUIAPI, zashboardVersion } from '@/api'
|
||||
import LanguageSelect from '@/components/settings/LanguageSelect.vue'
|
||||
import { useSettings } from '@/composables/settings'
|
||||
import { EMOJIS, FONTS, SETTINGS_MENU_KEY } from '@/constant'
|
||||
import { handlerUpgradeSuccess } from '@/helper'
|
||||
import { deleteBase64FromIndexedDB, LOCAL_IMAGE, saveBase64ToIndexedDB } from '@/helper/indexeddb'
|
||||
import { exportSettings, isPWA } from '@/helper/utils'
|
||||
import {
|
||||
autoTheme,
|
||||
autoUpgrade,
|
||||
blurIntensity,
|
||||
customBackgroundURL,
|
||||
darkTheme,
|
||||
dashboardTransparent,
|
||||
defaultTheme,
|
||||
emoji,
|
||||
font,
|
||||
hiddenSettingsItems,
|
||||
} from '@/store/settings'
|
||||
import {
|
||||
AdjustmentsHorizontalIcon,
|
||||
ArrowPathIcon,
|
||||
ArrowUpTrayIcon,
|
||||
PlusIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import ImportSettings from '../common/ImportSettings.vue'
|
||||
import TextInput from '../common/TextInput.vue'
|
||||
import CustomTheme from './CustomTheme.vue'
|
||||
import ThemeSelector from './ThemeSelector.vue'
|
||||
|
||||
const customThemeModal = ref(false)
|
||||
|
||||
// 检查是否有可见的子项
|
||||
const hasVisibleItems = computed(() => {
|
||||
return (
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.zashboardSettings.language`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.zashboardSettings.fonts`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.zashboardSettings.emoji`] ||
|
||||
!hiddenSettingsItems.value[
|
||||
`${SETTINGS_MENU_KEY.general}.zashboardSettings.customBackgroundURL`
|
||||
] ||
|
||||
(customBackgroundURL.value &&
|
||||
displayBgProperty.value &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.zashboardSettings.transparent`]) ||
|
||||
(customBackgroundURL.value &&
|
||||
displayBgProperty.value &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.zashboardSettings.blurIntensity`]) ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.zashboardSettings.defaultTheme`] ||
|
||||
(autoTheme.value &&
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.zashboardSettings.darkTheme`]) ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.zashboardSettings.autoSwitchTheme`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.zashboardSettings.autoUpgrade`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.zashboardSettings.upgradeUI`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.zashboardSettings.exportSettings`] ||
|
||||
!hiddenSettingsItems.value[`${SETTINGS_MENU_KEY.general}.zashboardSettings.importSettings`]
|
||||
)
|
||||
})
|
||||
const displayBgProperty = ref(false)
|
||||
const commitId = __COMMIT_ID__
|
||||
|
||||
watch(customBackgroundURL, (value) => {
|
||||
if (value) {
|
||||
displayBgProperty.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const inputFileRef = ref()
|
||||
const handlerClickUpload = () => {
|
||||
inputFileRef.value?.click()
|
||||
}
|
||||
const handlerBackgroundURLChange = () => {
|
||||
if (!customBackgroundURL.value.includes(LOCAL_IMAGE)) {
|
||||
deleteBase64FromIndexedDB()
|
||||
}
|
||||
}
|
||||
|
||||
const handlerFileChange = (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
customBackgroundURL.value = LOCAL_IMAGE + '-' + Date.now()
|
||||
saveBase64ToIndexedDB(reader.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const fontOptions = computed(() => {
|
||||
const mode = import.meta.env.MODE
|
||||
|
||||
if (Object.values(FONTS).includes(mode as FONTS)) {
|
||||
return [mode]
|
||||
}
|
||||
|
||||
return Object.values(FONTS)
|
||||
})
|
||||
|
||||
const { isUIUpdateAvailable } = useSettings()
|
||||
|
||||
const isUIUpgrading = ref(false)
|
||||
const handlerClickUpgradeUI = async () => {
|
||||
if (isUIUpgrading.value) return
|
||||
isUIUpgrading.value = true
|
||||
try {
|
||||
await upgradeUIAPI()
|
||||
isUIUpgrading.value = false
|
||||
handlerUpgradeSuccess()
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 1000)
|
||||
} catch {
|
||||
isUIUpgrading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshPages = async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
|
||||
for (const registration of registrations) {
|
||||
registration.unregister()
|
||||
}
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
17
src/components/sidebar/CommonCtrl.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 p-2 text-sm">
|
||||
<StatisticsStats type="ctrl" />
|
||||
<BackendSwitch :disable-edit-backend="true" />
|
||||
<div class="flex gap-2">
|
||||
<SidebarButtons />
|
||||
<BackendVersion />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BackendVersion from '../common/BackendVersion.vue'
|
||||
import StatisticsStats from '../overview/StatisticsStats.vue'
|
||||
import BackendSwitch from '../settings/BackendSwitch.vue'
|
||||
import SidebarButtons from './SidebarButtons.vue'
|
||||
</script>
|
||||
240
src/components/sidebar/ConnectionCtrl.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { disconnectAllAPI, disconnectByIdAPI } from '@/api'
|
||||
import { useCtrlsBar } from '@/composables/useCtrlsBar'
|
||||
import { ROUTE_NAME, SETTINGS_MENU_KEY, SORT_DIRECTION, SORT_TYPE } from '@/constant'
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import {
|
||||
connectionFilter,
|
||||
connections,
|
||||
connectionSortDirection,
|
||||
connectionSortType,
|
||||
isPaused,
|
||||
quickFilterEnabled,
|
||||
quickFilterRegex,
|
||||
renderConnections,
|
||||
} from '@/store/connections'
|
||||
import { useConnectionCard } from '@/store/settings'
|
||||
import {
|
||||
ArrowDownCircleIcon,
|
||||
ArrowUpCircleIcon,
|
||||
LinkIcon,
|
||||
LinkSlashIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import DialogWrapper from '../common/DialogWrapper.vue'
|
||||
import TextInput from '../common/TextInput.vue'
|
||||
import ConnectionCardSettings from '../settings/ConnectionCardSettings.vue'
|
||||
import TableSettings from '../settings/TableSettings.vue'
|
||||
import ConnectionTabs from './ConnectionTabs.vue'
|
||||
import SourceIPFilter from './SourceIPFilter.vue'
|
||||
|
||||
const handlerClickCloseAll = () => {
|
||||
if (renderConnections.value.length === connections.value.length) {
|
||||
disconnectAllAPI()
|
||||
} else {
|
||||
renderConnections.value.forEach((conn) => {
|
||||
disconnectByIdAPI(conn.id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ConnectionCtrl',
|
||||
components: {
|
||||
TextInput,
|
||||
ConnectionTabs,
|
||||
SourceIPFilter,
|
||||
},
|
||||
setup() {
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const settingsModel = ref(false)
|
||||
const { showTip, updateTip } = useTooltip()
|
||||
const { isLargeCtrlsBar } = useCtrlsBar(useConnectionCard.value ? 860 : 720)
|
||||
|
||||
return () => {
|
||||
const sortForCards = (
|
||||
<div
|
||||
class={`flex items-center gap-1 text-sm ${isLargeCtrlsBar.value ? 'w-auto' : 'w-full'}`}
|
||||
>
|
||||
<span class="shrink-0">{t('sortBy')}</span>
|
||||
<div class={`join flex-1 ${isLargeCtrlsBar.value ? 'min-w-46' : ''}`}>
|
||||
<select
|
||||
class="join-item select select-sm flex-1"
|
||||
v-model={connectionSortType.value}
|
||||
>
|
||||
{(Object.values(SORT_TYPE) as string[]).map((opt) => (
|
||||
<option
|
||||
key={opt}
|
||||
value={opt}
|
||||
>
|
||||
{t(opt) || opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
class="btn join-item btn-sm"
|
||||
onClick={() => {
|
||||
connectionSortDirection.value =
|
||||
connectionSortDirection.value === SORT_DIRECTION.ASC
|
||||
? SORT_DIRECTION.DESC
|
||||
: SORT_DIRECTION.ASC
|
||||
}}
|
||||
>
|
||||
{connectionSortDirection.value === SORT_DIRECTION.ASC ? (
|
||||
<ArrowUpCircleIcon class="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDownCircleIcon class="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const settingsModal = (
|
||||
<>
|
||||
<button
|
||||
class="btn btn-circle btn-sm"
|
||||
onClick={() => (settingsModel.value = true)}
|
||||
>
|
||||
<WrenchScrewdriverIcon class="h-4 w-4" />
|
||||
</button>
|
||||
<DialogWrapper
|
||||
v-model={settingsModel.value}
|
||||
title={t('connectionSettings')}
|
||||
>
|
||||
<div class="flex flex-col gap-4 p-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="shrink-0">{t('hideConnectionRegex')}</span>
|
||||
<TextInput
|
||||
class="w-32 max-w-64 flex-1"
|
||||
v-model={quickFilterRegex.value}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{t('hideConnection')}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
v-model={quickFilterEnabled.value}
|
||||
/>
|
||||
<div
|
||||
onMouseenter={(e) =>
|
||||
showTip(e, t('hideConnectionTip'), {
|
||||
appendTo: 'parent',
|
||||
})
|
||||
}
|
||||
>
|
||||
<QuestionMarkCircleIcon class="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
{useConnectionCard.value ? <ConnectionCardSettings /> : <TableSettings />}
|
||||
<div class="divider m-0"></div>
|
||||
<button
|
||||
class="btn btn-block"
|
||||
onClick={() => {
|
||||
settingsModel.value = false
|
||||
router.push({
|
||||
name: ROUTE_NAME.settings,
|
||||
query: { scrollTo: SETTINGS_MENU_KEY.connections },
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('moreSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
</>
|
||||
)
|
||||
|
||||
const searchInput = (
|
||||
<TextInput
|
||||
v-model={connectionFilter.value}
|
||||
placeholder={`${t('search')} | ${t('searchMultiple')}`}
|
||||
clearable={true}
|
||||
before-close={true}
|
||||
class={isLargeCtrlsBar.value ? 'w-32 max-w-80 flex-1' : 'w-full'}
|
||||
/>
|
||||
)
|
||||
|
||||
const buttons = (
|
||||
<>
|
||||
<button
|
||||
class="btn btn-circle btn-sm"
|
||||
onClick={() => {
|
||||
quickFilterEnabled.value = !quickFilterEnabled.value
|
||||
updateTip(quickFilterEnabled.value ? t('showConnection') : t('hideConnection'))
|
||||
}}
|
||||
onMouseenter={(e) =>
|
||||
showTip(e, quickFilterEnabled.value ? t('showConnection') : t('hideConnection'), {
|
||||
appendTo: 'parent',
|
||||
})
|
||||
}
|
||||
>
|
||||
{quickFilterEnabled.value ? (
|
||||
<LinkSlashIcon class="h-4 w-4" />
|
||||
) : (
|
||||
<LinkIcon class="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-circle btn-sm"
|
||||
onClick={() => {
|
||||
isPaused.value = !isPaused.value
|
||||
}}
|
||||
>
|
||||
{isPaused.value ? <PlayIcon class="h-4 w-4" /> : <PauseIcon class="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-circle btn-sm"
|
||||
onClick={handlerClickCloseAll}
|
||||
>
|
||||
<XMarkIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
const content = !isLargeCtrlsBar.value ? (
|
||||
<div class="flex flex-wrap items-center gap-2 p-2">
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<ConnectionTabs />
|
||||
{!useConnectionCard.value && (
|
||||
<div class="flex items-center gap-1">
|
||||
{settingsModal}
|
||||
{buttons}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{useConnectionCard.value && (
|
||||
<div class="flex w-full items-center gap-2">
|
||||
{sortForCards}
|
||||
{settingsModal}
|
||||
{buttons}
|
||||
</div>
|
||||
)}
|
||||
<div class="join w-full">
|
||||
<SourceIPFilter class="w-40" />
|
||||
{searchInput}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex items-center gap-2 p-2">
|
||||
<ConnectionTabs />
|
||||
{useConnectionCard.value && sortForCards}
|
||||
<SourceIPFilter class="w-40" />
|
||||
<div class="flex flex-1">{searchInput}</div>
|
||||
{settingsModal}
|
||||
{buttons}
|
||||
</div>
|
||||
)
|
||||
|
||||
return <div class="ctrls-bar">{content}</div>
|
||||
}
|
||||
},
|
||||
})
|
||||
34
src/components/sidebar/ConnectionTabs.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="tabs-box tabs tabs-xs">
|
||||
<a
|
||||
v-for="tab in Object.values(CONNECTION_TAB_TYPE)"
|
||||
:key="tab"
|
||||
role="tab"
|
||||
:class="twMerge('tab', connectionTabShow === tab && 'tab-active', !horizental && 'flex-1')"
|
||||
@click="() => (connectionTabShow = tab)"
|
||||
>{{ $t(tab) }}
|
||||
<template v-if="connectionTabShow === tab"> ({{ connectionsCount }}) </template>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CONNECTION_TAB_TYPE } from '@/constant'
|
||||
import { connections, connectionTabShow, renderConnections } from '@/store/connections'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineProps({
|
||||
horizental: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
const connectionsCount = computed(() => {
|
||||
if (renderConnections.value.length !== connections.value.length) {
|
||||
return `${renderConnections.value.length} / ${connections.value.length}`
|
||||
}
|
||||
|
||||
return connections.value.length
|
||||
})
|
||||
</script>
|
||||
316
src/components/sidebar/LogsCtrl.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { isSingBox } from '@/api'
|
||||
import { useCtrlsBar } from '@/composables/useCtrlsBar'
|
||||
import { LOG_LEVEL } from '@/constant'
|
||||
import { useTooltip } from '@/helper/tooltip'
|
||||
import {
|
||||
initLogs,
|
||||
isPaused,
|
||||
logFilter,
|
||||
logFilterEnabled,
|
||||
logFilterRegex,
|
||||
logLevel,
|
||||
logTypeFilter,
|
||||
logs,
|
||||
} from '@/store/logs'
|
||||
import { logRetentionLimit, logSearchHistory } from '@/store/settings'
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
LinkIcon,
|
||||
LinkSlashIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import dayjs from 'dayjs'
|
||||
import { debounce } from 'lodash'
|
||||
import { computed, defineComponent, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import DialogWrapper from '../common/DialogWrapper.vue'
|
||||
import TextInput from '../common/TextInput.vue'
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { t } = useI18n()
|
||||
const settingsModel = ref(false)
|
||||
const { isLargeCtrlsBar } = useCtrlsBar()
|
||||
const { showTip, updateTip } = useTooltip()
|
||||
const insertLogSearchHistory = debounce((log: string) => {
|
||||
if (!log) {
|
||||
return
|
||||
}
|
||||
|
||||
const idx = logSearchHistory.value.indexOf(log)
|
||||
|
||||
if (idx !== -1) {
|
||||
logSearchHistory.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
logSearchHistory.value.unshift(log)
|
||||
if (logSearchHistory.value.length > 5) {
|
||||
logSearchHistory.value.pop()
|
||||
}
|
||||
}, 1500)
|
||||
|
||||
watch(logFilter, insertLogSearchHistory)
|
||||
|
||||
const logLevels = computed(() => {
|
||||
if (isSingBox.value) {
|
||||
return Object.values(LOG_LEVEL)
|
||||
}
|
||||
return [LOG_LEVEL.Debug, LOG_LEVEL.Info, LOG_LEVEL.Warning, LOG_LEVEL.Error, LOG_LEVEL.Silent]
|
||||
})
|
||||
|
||||
const logFilterOptions = computed(() => {
|
||||
const types: string[] = []
|
||||
const levels: string[] = []
|
||||
|
||||
if (isSingBox.value) {
|
||||
for (const log of logs.value) {
|
||||
const startIndex = log.payload.startsWith('[') ? log.payload.indexOf(']') + 2 : 0
|
||||
const endIndex = log.payload.indexOf(':', startIndex)
|
||||
const type = log.payload.slice(startIndex, endIndex + 1)
|
||||
|
||||
if (!types.includes(type)) {
|
||||
types.push(type)
|
||||
}
|
||||
|
||||
if (!levels.includes(log.type)) {
|
||||
levels.push(log.type)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const log of logs.value) {
|
||||
const index = log.payload.indexOf(' ')
|
||||
const type = index === -1 ? log.payload : log.payload.slice(0, index)
|
||||
|
||||
if (!types.includes(type)) {
|
||||
types.push(type)
|
||||
}
|
||||
|
||||
if (!levels.includes(log.type)) {
|
||||
levels.push(log.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
levels: levels.sort((a, b) => {
|
||||
const aIdx = logLevels.value.indexOf(a as LOG_LEVEL)
|
||||
const bIdx = logLevels.value.indexOf(b as LOG_LEVEL)
|
||||
return aIdx - bIdx
|
||||
}),
|
||||
types: types.sort(),
|
||||
}
|
||||
})
|
||||
|
||||
const downloadAllLogs = () => {
|
||||
const blob = new Blob(
|
||||
[
|
||||
logs.value
|
||||
.map((log) =>
|
||||
[
|
||||
log.seq.toString().padEnd(5, ' '),
|
||||
log.time,
|
||||
log.type.padEnd(7, ' '),
|
||||
log.payload,
|
||||
].join('\t'),
|
||||
)
|
||||
.join('\n'),
|
||||
],
|
||||
{
|
||||
type: 'text/plain',
|
||||
},
|
||||
)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = dayjs().format('YYYY-MM-DD HH-mm-ss') + '.log'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return () => {
|
||||
const levelSelect = (
|
||||
<select
|
||||
class={['join-item select select-sm min-w-30']}
|
||||
v-model={logLevel.value}
|
||||
onChange={initLogs}
|
||||
>
|
||||
{logLevels.value.map((opt) => (
|
||||
<option
|
||||
key={opt}
|
||||
value={opt}
|
||||
>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
const searchInput = (
|
||||
<TextInput
|
||||
v-model={logFilter.value}
|
||||
beforeClose={true}
|
||||
class="flex-1"
|
||||
placeholder={`${t('search')} | Regex`}
|
||||
clearable={true}
|
||||
menus={logSearchHistory.value}
|
||||
menusDeleteable={true}
|
||||
onUpdate:menus={(val) => (logSearchHistory.value = val)}
|
||||
/>
|
||||
)
|
||||
|
||||
const logTypeSelect = (
|
||||
<select
|
||||
class={[
|
||||
'join-item select select-sm',
|
||||
isLargeCtrlsBar.value ? 'w-36' : 'w-24 max-w-40 flex-1',
|
||||
]}
|
||||
v-model={logTypeFilter.value}
|
||||
>
|
||||
<option value="">{t('all')}</option>
|
||||
<optgroup label={t('logLevel')}>
|
||||
{logFilterOptions.value.levels.map((opt) => (
|
||||
<option
|
||||
key={opt}
|
||||
value={opt}
|
||||
>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label={t('logType')}>
|
||||
{logFilterOptions.value.types.map((opt) => (
|
||||
<option
|
||||
key={opt}
|
||||
value={opt}
|
||||
>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
)
|
||||
|
||||
const settingsModal = (
|
||||
<>
|
||||
<button
|
||||
class={'btn btn-circle btn-sm'}
|
||||
onClick={() => (settingsModel.value = true)}
|
||||
>
|
||||
<WrenchScrewdriverIcon class="h-4 w-4" />
|
||||
</button>
|
||||
<DialogWrapper
|
||||
v-model={settingsModel.value}
|
||||
title={t('logSettings')}
|
||||
>
|
||||
<div class="flex flex-col gap-4 p-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
{t('logRetentionLimit')}
|
||||
<input
|
||||
class="input input-sm w-20"
|
||||
type="number"
|
||||
max="9999"
|
||||
v-model={logRetentionLimit.value}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="shrink-0">{t('hideLogRegex')}</span>
|
||||
<TextInput
|
||||
class="w-32 max-w-64 flex-1"
|
||||
v-model={logFilterRegex.value}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{t('hideLog')}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle"
|
||||
v-model={logFilterEnabled.value}
|
||||
/>
|
||||
<div
|
||||
onMouseenter={(e) =>
|
||||
showTip(e, t('hideLogTip'), {
|
||||
appendTo: 'parent',
|
||||
})
|
||||
}
|
||||
>
|
||||
<QuestionMarkCircleIcon class="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
</>
|
||||
)
|
||||
|
||||
const buttons = (
|
||||
<div class="flex items-center gap-2">
|
||||
{settingsModal}
|
||||
<button
|
||||
class="btn btn-circle btn-sm"
|
||||
onClick={downloadAllLogs}
|
||||
>
|
||||
<ArrowDownTrayIcon class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-circle btn-sm"
|
||||
onClick={() => {
|
||||
logFilterEnabled.value = !logFilterEnabled.value
|
||||
updateTip(logFilterEnabled.value ? t('showLog') : t('hideLog'))
|
||||
}}
|
||||
onMouseenter={(e) =>
|
||||
showTip(e, logFilterEnabled.value ? t('showLog') : t('hideLog'), {
|
||||
appendTo: 'parent',
|
||||
})
|
||||
}
|
||||
>
|
||||
{logFilterEnabled.value ? (
|
||||
<LinkSlashIcon class="h-4 w-4" />
|
||||
) : (
|
||||
<LinkIcon class="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-circle btn-sm"
|
||||
onClick={() => (isPaused.value = !isPaused.value)}
|
||||
>
|
||||
{isPaused.value ? <PlayIcon class="h-4 w-4" /> : <PauseIcon class="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-circle btn-sm"
|
||||
onClick={() => (logs.value = [])}
|
||||
>
|
||||
<XMarkIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const content = !isLargeCtrlsBar.value ? (
|
||||
<div class="flex flex-col gap-2 p-2">
|
||||
<div class="flex w-full justify-between gap-2">
|
||||
<div class="join flex-1">{levelSelect}</div>
|
||||
{buttons}
|
||||
</div>
|
||||
<div class="join">
|
||||
{logTypeSelect}
|
||||
{searchInput}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex items-center justify-between gap-2 p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{levelSelect}
|
||||
<div class="join w-96">
|
||||
{logTypeSelect}
|
||||
{searchInput}
|
||||
</div>
|
||||
</div>
|
||||
{buttons}
|
||||
</div>
|
||||
)
|
||||
|
||||
return <div class="ctrls-bar">{content}</div>
|
||||
}
|
||||
},
|
||||
})
|
||||