Merge Official Source

This commit is contained in:
Xiaokailnol
2026-02-03 22:48:23 +08:00
commit 399c441e7a
161 changed files with 28630 additions and 0 deletions

2260
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

7
Caddyfile Normal file
View File

@@ -0,0 +1,7 @@
:80 {
file_server
root * .
try_files {path} /index.html
}

19
Dockerfile Normal file
View 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
View 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
View 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. 面板支持PWAProgressive 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.

1
env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

31
eslint.config.js Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

7
postcss.config.js Normal file
View File

@@ -0,0 +1,7 @@
export default {
plugins: {
'postcss-for': {},
'postcss-conditionals': {},
'@tailwindcss/postcss': {},
},
}

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

1
public/favicon-dark.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

1
public/favicon.svg Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
readme/mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

BIN
readme/pc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

148
src/App.vue Normal file
View 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
View 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
View 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
View 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')
}

Binary file not shown.

Binary file not shown.

33
src/assets/load-fonts.ts Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

37
src/assets/sing-box.svg Normal file
View 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
View 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;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>
)
}
},
})

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>
}
},
})

View 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>

View 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>
}
},
})

Some files were not shown because too many files have changed in this diff Show More