├── .dockerignore ├── .husky └── pre-commit ├── env.d.ts ├── .github └── ISSUE_TEMPLATE │ ├── config.yml │ ├── 4-功能请求.yml │ ├── 3-feature-request.yml │ ├── 2-缺陷报告.yml │ └── 1-bug-report.yml ├── readme ├── pc.png └── mobile.png ├── public ├── favicon.ico ├── pwa-192x192.png ├── pwa-512x512.png ├── apple-touch-icon.png ├── pwa-maskable-192x192.png ├── pwa-maskable-512x512.png ├── favicon.svg ├── icon.svg └── favicon-dark.svg ├── Caddyfile ├── src ├── assets │ ├── metacubex.jpg │ ├── NotoColorEmoji-flagsonly.ttf │ ├── TwemojiMozilla-flags.woff2 │ ├── theme.css │ ├── load-fonts.ts │ └── sing-box.svg ├── components │ ├── proxies │ │ ├── ProxyNodeGrid.vue │ │ ├── ProxyName.vue │ │ ├── ProxiesContent.vue │ │ ├── ProxyIcon.vue │ │ ├── ProxyGroupNow.vue │ │ ├── ProxiesByProvider.vue │ │ ├── LatencyTag.vue │ │ ├── ProxyNodeCard.vue │ │ ├── ProxyGroup.vue │ │ └── ProxyPreview.vue │ ├── overview │ │ ├── NetworkCard.vue │ │ ├── ChartsCard.vue │ │ ├── MemoryCharts.vue │ │ ├── ConnectionsCharts.vue │ │ ├── SpeedCharts.vue │ │ ├── OverviewCardSettingsDialog.vue │ │ ├── StatisticsStats.vue │ │ ├── ConnectionStatus.vue │ │ └── IPCheck.vue │ ├── sidebar │ │ ├── CommonCtrl.vue │ │ ├── OverviewCarousel.vue │ │ ├── ConnectionTabs.vue │ │ ├── OverviewCtrl.vue │ │ ├── VerticalInfos.vue │ │ ├── SidebarButtons.vue │ │ ├── SideBar.vue │ │ └── SourceIPFilter.vue │ ├── common │ │ ├── BackendVersion.vue │ │ ├── ProxyChains.vue │ │ ├── CollapseCard.vue │ │ ├── DialogWrapper.vue │ │ ├── VirtualScroller.vue │ │ ├── TextInput.vue │ │ └── ImportSettings.vue │ ├── connections │ │ ├── ConnectionCardList.vue │ │ └── ConnectionDetails.vue │ ├── settings │ │ ├── LanguageSelect.vue │ │ ├── OverviewCard.vue │ │ ├── TableSettings.vue │ │ ├── BackendSwitch.vue │ │ ├── ThemeSelector.vue │ │ ├── DnsQuery.vue │ │ ├── UpgradeCoreModal.vue │ │ ├── ConnectionCardSettings.vue │ │ ├── IconSettings.vue │ │ ├── OverviewSettings.vue │ │ ├── SourceIPLabels.vue │ │ ├── GroupTestUrlsSettings.vue │ │ ├── ConnectionsSettings.vue │ │ └── SourceIPInput.vue │ ├── logs │ │ └── LogsCard.vue │ └── rules │ │ └── RuleProvider.vue ├── types │ ├── global.d.ts │ └── index.d.ts ├── i18n │ └── index.ts ├── composables │ ├── overview.ts │ ├── connections.ts │ ├── settings.ts │ ├── useCtrlsBar.ts │ ├── paddingViews.ts │ ├── statistics.ts │ ├── bouncein.ts │ ├── keyboard.ts │ ├── proxiesScroll.ts │ ├── proxies.ts │ ├── renderProxies.ts │ └── swipe.ts ├── main.ts ├── store │ ├── config.ts │ ├── smart.ts │ ├── setup.ts │ ├── rules.ts │ ├── logs.ts │ └── overview.ts ├── helper │ ├── dayjs.ts │ ├── sourceip.ts │ ├── autoImportSettings.ts │ ├── tooltip.ts │ └── utils.ts ├── api │ └── latency.ts ├── views │ ├── ConnectionsPage.vue │ ├── OverviewPage.vue │ ├── LogsPage.vue │ ├── RulesPage.vue │ └── ProxiesPage.vue ├── router │ └── index.ts └── App.vue ├── .lintstagedrc.yaml ├── postcss.config.js ├── .editorconfig ├── tsconfig.json ├── .prettierrc.json ├── Dockerfile ├── tsconfig.app.json ├── .gitignore ├── tsconfig.node.json ├── tailwind.config.ts ├── eslint.config.js ├── LICENSE ├── index.html ├── vite.config.ts └── package.json /.dockerignore: -------------------------------------------------------------------------------- 1 | ./node_modules -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm type-check 2 | pnpm lint-staged -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /readme/pc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zephyruso/zashboard/HEAD/readme/pc.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zephyruso/zashboard/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /readme/mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zephyruso/zashboard/HEAD/readme/mobile.png -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | :80 { 2 | file_server 3 | 4 | root * . 5 | 6 | try_files {path} /index.html 7 | } -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zephyruso/zashboard/HEAD/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zephyruso/zashboard/HEAD/public/pwa-512x512.png -------------------------------------------------------------------------------- /src/assets/metacubex.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zephyruso/zashboard/HEAD/src/assets/metacubex.jpg -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zephyruso/zashboard/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/pwa-maskable-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zephyruso/zashboard/HEAD/public/pwa-maskable-192x192.png -------------------------------------------------------------------------------- /public/pwa-maskable-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zephyruso/zashboard/HEAD/public/pwa-maskable-512x512.png -------------------------------------------------------------------------------- /src/assets/NotoColorEmoji-flagsonly.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zephyruso/zashboard/HEAD/src/assets/NotoColorEmoji-flagsonly.ttf -------------------------------------------------------------------------------- /src/assets/TwemojiMozilla-flags.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zephyruso/zashboard/HEAD/src/assets/TwemojiMozilla-flags.woff2 -------------------------------------------------------------------------------- /.lintstagedrc.yaml: -------------------------------------------------------------------------------- 1 | 'package.json': 'sort-package-json' 2 | '*.{js,jsx,ts,tsx,vue}': 'eslint --fix' 3 | '*.{js,jsx,ts,tsx,vue,html,css,json,yml,yaml}': 'prettier --write' 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | 'postcss-for': {}, 4 | 'postcss-conditionals': {}, 5 | '@tailwindcss/postcss': {}, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}] 2 | charset = utf-8 3 | indent_size = 2 4 | indent_style = space 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "plugins": [ 4 | "prettier-plugin-organize-imports", 5 | "prettier-plugin-tailwindcss" 6 | ], 7 | "$schema": "https://json.schemastore.org/prettierrc", 8 | "semi": false, 9 | "singleAttributePerLine": true, 10 | "singleQuote": true, 11 | "printWidth": 100 12 | } 13 | -------------------------------------------------------------------------------- /src/components/proxies/ProxyNodeGrid.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 docker.io/guergeiro/pnpm:lts-latest AS builder 2 | 3 | WORKDIR /build 4 | 5 | COPY . . 6 | 7 | RUN pnpm install 8 | RUN pnpm build 9 | 10 | FROM docker.io/caddy:alpine 11 | 12 | EXPOSE 80 13 | 14 | WORKDIR /srv 15 | 16 | COPY --from=builder /build/dist/. . 17 | COPY Caddyfile . 18 | 19 | CMD ["caddy", "run"] 20 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const __APP_VERSION__: string 2 | declare const __COMMIT_ID__: string 3 | 4 | declare module 'vue-virtual-scroller' 5 | declare interface Navigator { 6 | standalone?: boolean 7 | } 8 | 9 | type ToolTipParams = { 10 | data: { 11 | value: number 12 | name: number 13 | } 14 | seriesName: string 15 | color: string 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 8 | "noUncheckedIndexedAccess": false, 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | pnpm-debug.log* 7 | lerna-debug.log* 8 | 9 | node_modules 10 | .DS_Store 11 | dist 12 | dist-ssr 13 | coverage 14 | *.local 15 | 16 | /cypress/videos/ 17 | /cypress/screenshots/ 18 | 19 | # Editor directories and files 20 | .vscode/* 21 | !.vscode/extensions.json 22 | .idea 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | 29 | *.tsbuildinfo 30 | -------------------------------------------------------------------------------- /src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { LANG } from '@/constant' 2 | import { language } from '@/store/settings' 3 | import { createI18n } from 'vue-i18n' 4 | import en from './en' 5 | import ru from './ru' 6 | import zh from './zh' 7 | import zhTW from './zh-tw' 8 | 9 | export const i18n = createI18n({ 10 | legacy: false, 11 | locale: language.value, 12 | messages: { 13 | [LANG.EN_US]: en, 14 | [LANG.ZH_CN]: zh, 15 | [LANG.ZH_TW]: zhTW, 16 | [LANG.RU_RU]: ru, 17 | }, 18 | }) 19 | -------------------------------------------------------------------------------- /src/composables/overview.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | type IPInfo = { 4 | ip: string[] 5 | ipWithPrivacy: string[] 6 | } 7 | 8 | export const ipForChina = ref({ 9 | ip: [], 10 | ipWithPrivacy: [], 11 | }) 12 | export const ipForGlobal = ref({ 13 | ip: [], 14 | ipWithPrivacy: [], 15 | }) 16 | 17 | export const baiduLatency = ref('') 18 | export const githubLatency = ref('') 19 | export const youtubeLatency = ref('') 20 | export const cloudflareLatency = ref('') 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/4-功能请求.yml: -------------------------------------------------------------------------------- 1 | name: 功能请求 2 | description: 为这个项目提出一个想法 3 | title: '[Feature]: ' 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | 感谢您提出功能建议!在提交之前,请搜索[现有issues](https://github.com/Zephyruso/zashboard/issues)看看您的想法是否已经被提出过。 9 | 10 | - type: textarea 11 | id: feature-description 12 | attributes: 13 | label: 功能描述 14 | description: 请提供您想要请求的功能的清晰简洁的描述 15 | placeholder: '请详细描述您想要的功能' 16 | validations: 17 | required: true 18 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node22/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "noEmit": true, 13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 14 | 15 | "module": "ESNext", 16 | "moduleResolution": "Bundler", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import '@/helper/dayjs' 2 | import 'tippy.js/animations/scale.css' 3 | import 'tippy.js/dist/tippy.css' 4 | import { createApp } from 'vue' 5 | import App from './App.vue' 6 | import { loadFonts } from './assets/load-fonts' 7 | import './assets/main.css' 8 | import './assets/theme.css' 9 | import { applyCustomThemes } from './helper' 10 | import { i18n } from './i18n' 11 | import router from './router' 12 | 13 | applyCustomThemes() 14 | loadFonts() 15 | 16 | const app = createApp(App) 17 | 18 | app.use(router) 19 | app.use(i18n) 20 | app.mount('#app') 21 | -------------------------------------------------------------------------------- /src/composables/connections.ts: -------------------------------------------------------------------------------- 1 | import type { Connection } from '@/types' 2 | import { nextTick, ref } from 'vue' 3 | 4 | const infoConn = ref(null) 5 | const connectionDetailModalShow = ref(false) 6 | 7 | export const useConnections = () => { 8 | const handlerInfo = async (conn: Connection) => { 9 | infoConn.value = null 10 | await nextTick() 11 | infoConn.value = conn 12 | connectionDetailModalShow.value = true 13 | } 14 | 15 | return { 16 | infoConn, 17 | connectionDetailModalShow, 18 | handlerInfo, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/composables/settings.ts: -------------------------------------------------------------------------------- 1 | import { fetchIsUIUpdateAvailable, upgradeUIAPI } from '@/api' 2 | import { autoUpgrade } from '@/store/settings' 3 | import { ref } from 'vue' 4 | 5 | const isUIUpdateAvailable = ref(false) 6 | 7 | export const useSettings = () => { 8 | const checkUIUpdate = async () => { 9 | isUIUpdateAvailable.value = await fetchIsUIUpdateAvailable() 10 | if (isUIUpdateAvailable.value && autoUpgrade.value) { 11 | upgradeUIAPI() 12 | } 13 | } 14 | 15 | return { 16 | isUIUpdateAvailable, 17 | checkUIUpdate, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/overview/NetworkCard.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 19 | -------------------------------------------------------------------------------- /src/components/sidebar/CommonCtrl.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | -------------------------------------------------------------------------------- /src/components/common/BackendVersion.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 22 | -------------------------------------------------------------------------------- /src/components/common/ProxyChains.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: '[Feature]: ' 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for suggesting a feature! Before submitting, please search [existing issues](https://github.com/Zephyruso/zashboard/issues) to see if your idea has already been proposed. 9 | 10 | - type: textarea 11 | id: feature-description 12 | attributes: 13 | label: Feature Description 14 | description: Please provide a clear and concise description of the feature you'd like to request 15 | placeholder: 'Please describe the feature you want in detail' 16 | validations: 17 | required: true 18 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import plugin from 'tailwindcss/plugin' 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], 6 | theme: { 7 | extend: { 8 | colors: { 9 | ['low-latency']: 'oklch(0.648 0.15 160)', 10 | ['medium-latency']: 'rgb(250, 210, 75)', 11 | ['high-latency']: 'rgb(244, 96, 108)', 12 | }, 13 | }, 14 | }, 15 | plugins: [ 16 | plugin(({ addUtilities }) => { 17 | addUtilities({ 18 | '.scrollbar-hidden': { 19 | 'scrollbar-width': 'none!important', 20 | }, 21 | '.scrollbar-thin': { 22 | 'scrollbar-width': 'thin', 23 | }, 24 | }) 25 | }), 26 | ], 27 | } 28 | -------------------------------------------------------------------------------- /src/store/config.ts: -------------------------------------------------------------------------------- 1 | import { getConfigsAPI, patchConfigsAPI } from '@/api' 2 | import type { Config } from '@/types' 3 | import { ref } from 'vue' 4 | 5 | export const configs = ref({ 6 | port: 0, 7 | 'socks-port': 0, 8 | 'redir-port': 0, 9 | 'tproxy-port': 0, 10 | 'mixed-port': 0, 11 | 'allow-lan': false, 12 | 'bind-address': '', 13 | mode: '', 14 | 'mode-list': [], 15 | modes: [], 16 | 'log-level': '', 17 | ipv6: false, 18 | tun: { 19 | enable: false, 20 | }, 21 | }) 22 | export const fetchConfigs = async () => { 23 | configs.value = (await getConfigsAPI()).data 24 | } 25 | export const updateConfigs = async (cfg: Record) => { 26 | await patchConfigsAPI(cfg) 27 | fetchConfigs() 28 | } 29 | -------------------------------------------------------------------------------- /src/composables/useCtrlsBar.ts: -------------------------------------------------------------------------------- 1 | import { useCurrentElement, useElementBounding } from '@vueuse/core' 2 | import { computed, onUnmounted, watch } from 'vue' 3 | import { ctrlsBottom } from './paddingViews' 4 | 5 | export function useCtrlsBar(width: number = 720) { 6 | const element = useCurrentElement() 7 | const { width: ctrlsBarWidth, bottom: ctrlsBarBottom } = useElementBounding(element) 8 | const isLargeCtrlsBar = computed(() => { 9 | return ctrlsBarWidth.value > width 10 | }) 11 | 12 | watch( 13 | ctrlsBarBottom, 14 | () => { 15 | ctrlsBottom.value = ctrlsBarBottom.value 16 | }, 17 | { immediate: true }, 18 | ) 19 | 20 | onUnmounted(() => { 21 | ctrlsBottom.value = 0 22 | }) 23 | 24 | return { 25 | isLargeCtrlsBar, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/sidebar/OverviewCarousel.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 24 | -------------------------------------------------------------------------------- /src/components/overview/ChartsCard.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 24 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import pluginVue from 'eslint-plugin-vue' 2 | import vueTsEslintConfig from '@vue/eslint-config-typescript' 3 | import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' 4 | 5 | export default [ 6 | { 7 | name: 'app/files-to-lint', 8 | files: ['**/*.{ts,mts,tsx,vue}'], 9 | }, 10 | 11 | { 12 | name: 'app/files-to-ignore', 13 | ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'], 14 | }, 15 | 16 | ...pluginVue.configs['flat/essential'], 17 | ...vueTsEslintConfig({ 18 | rules: { 19 | 'vue/singleline-html-element-content-newline': 'off', 20 | 'vue/multiline-html-element-content-newline': 'off', 21 | 'vue/max-attributes-per-line': [ 22 | 'error', 23 | { 24 | 'singleline': 1, 25 | 'multiline': 1 26 | } 27 | ] 28 | } 29 | }), 30 | skipFormatting, 31 | ] 32 | -------------------------------------------------------------------------------- /src/components/connections/ConnectionCardList.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 27 | -------------------------------------------------------------------------------- /src/helper/dayjs.ts: -------------------------------------------------------------------------------- 1 | import { language } from '@/store/settings' 2 | import dayjs from 'dayjs' 3 | import 'dayjs/locale/ru' 4 | import 'dayjs/locale/zh-cn' 5 | import 'dayjs/locale/zh-tw' 6 | import relativeTime from 'dayjs/plugin/relativeTime' 7 | import updateLocale from 'dayjs/plugin/updateLocale' 8 | import { watch } from 'vue' 9 | 10 | dayjs.extend(relativeTime) 11 | dayjs.extend(updateLocale) 12 | dayjs.updateLocale('en', { 13 | relativeTime: { 14 | future: 'in %s', 15 | past: '%s ago', 16 | s: 'seconds', 17 | m: 'a minute', 18 | mm: '%d minutes', 19 | h: 'an hour', 20 | hh: '%d hours', 21 | d: 'a day', 22 | dd: '%d days', 23 | M: 'a month', 24 | MM: '%d months', 25 | y: 'a year', 26 | yy: '%d years', 27 | }, 28 | }) 29 | 30 | watch( 31 | () => language.value, 32 | () => { 33 | dayjs.locale(language.value) 34 | }, 35 | { immediate: true }, 36 | ) 37 | -------------------------------------------------------------------------------- /src/components/settings/LanguageSelect.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 35 | -------------------------------------------------------------------------------- /src/components/proxies/ProxyName.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 39 | -------------------------------------------------------------------------------- /src/components/proxies/ProxiesContent.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 33 | -------------------------------------------------------------------------------- /src/composables/paddingViews.ts: -------------------------------------------------------------------------------- 1 | import { isMiddleScreen } from '@/helper/utils' 2 | import { computed, ref } from 'vue' 3 | 4 | export const ctrlsBottom = ref(0) 5 | export const dockTop = ref(0) 6 | export const usePaddingForViews = ( 7 | config = { 8 | offsetTop: 8, 9 | offsetBottom: 8, 10 | }, 11 | ) => { 12 | const { offsetTop, offsetBottom } = config 13 | const paddingTop = computed(() => { 14 | if (isMiddleScreen.value) { 15 | return ctrlsBottom.value + offsetTop 16 | } 17 | return 0 18 | }) 19 | const paddingBottom = computed(() => { 20 | if (isMiddleScreen.value) { 21 | return dockTop.value + offsetBottom 22 | } 23 | return 0 24 | }) 25 | 26 | const padding = computed(() => { 27 | if (isMiddleScreen.value) { 28 | return { 29 | paddingTop: `${paddingTop.value}px`, 30 | paddingBottom: `${paddingBottom.value}px`, 31 | } 32 | } 33 | return {} 34 | }) 35 | 36 | return { 37 | padding, 38 | paddingTop, 39 | paddingBottom, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Zephyruso 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | 9 | -------------------------------------------------------------------------------- /src/composables/statistics.ts: -------------------------------------------------------------------------------- 1 | import { prettyBytesHelper } from '@/helper/utils' 2 | import { activeConnections, downloadTotal, uploadTotal } from '@/store/connections' 3 | import { downloadSpeed, memory, uploadSpeed } from '@/store/overview' 4 | import { computed } from 'vue' 5 | 6 | export enum STATISTICS_TYPE { 7 | CONNECTIONS = 'connections', 8 | DOWNLOAD = 'download', 9 | DL_SPEED = 'dlSpeed', 10 | MEMORY_USAGE = 'memoryUsage', 11 | UPLOAD = 'upload', 12 | UL_SPEED = 'ulSpeed', 13 | } 14 | 15 | export const statisticsMap = computed(() => { 16 | return { 17 | [STATISTICS_TYPE.CONNECTIONS]: activeConnections.value.length, 18 | [STATISTICS_TYPE.MEMORY_USAGE]: prettyBytesHelper(memory.value, { binary: true }), 19 | [STATISTICS_TYPE.DOWNLOAD]: prettyBytesHelper(downloadTotal.value), 20 | [STATISTICS_TYPE.UPLOAD]: prettyBytesHelper(uploadTotal.value), 21 | [STATISTICS_TYPE.DL_SPEED]: prettyBytesHelper(downloadSpeed.value) + '/s', 22 | [STATISTICS_TYPE.UL_SPEED]: prettyBytesHelper(uploadSpeed.value) + '/s', 23 | } 24 | }) 25 | -------------------------------------------------------------------------------- /src/components/overview/MemoryCharts.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 41 | -------------------------------------------------------------------------------- /src/components/proxies/ProxyIcon.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 49 | -------------------------------------------------------------------------------- /src/api/latency.ts: -------------------------------------------------------------------------------- 1 | const getLatencyFromUrlAPI = (url: string) => { 2 | return new Promise((resolve) => { 3 | const startTime = performance.now() 4 | const img = document.createElement('img') 5 | img.src = url + '?_=' + new Date().getTime() 6 | img.style.display = 'none' 7 | img.onload = () => { 8 | const endTime = performance.now() 9 | img.remove() 10 | 11 | resolve(endTime - startTime) 12 | } 13 | img.onerror = () => { 14 | img.remove() 15 | 16 | resolve(0) 17 | } 18 | 19 | document.body.appendChild(img) 20 | }) 21 | } 22 | 23 | export const getCloudflareLatencyAPI = () => { 24 | return getLatencyFromUrlAPI('https://www.cloudflare.com/favicon.ico') 25 | } 26 | 27 | export const getYouTubeLatencyAPI = () => { 28 | return getLatencyFromUrlAPI('https://yt3.ggpht.com/favicon.ico') 29 | } 30 | 31 | export const getGithubLatencyAPI = () => { 32 | return getLatencyFromUrlAPI('https://github.githubassets.com/favicon.ico') 33 | } 34 | 35 | export const getBaiduLatencyAPI = () => { 36 | return getLatencyFromUrlAPI('https://apps.bdimg.com/favicon.ico') 37 | } 38 | -------------------------------------------------------------------------------- /src/composables/bouncein.ts: -------------------------------------------------------------------------------- 1 | import { isMiddleScreen } from '@/helper/utils' 2 | import { scrollAnimationEffect } from '@/store/settings' 3 | import { useCurrentElement, useElementVisibility } from '@vueuse/core' 4 | import { onMounted, watch, type Ref } from 'vue' 5 | 6 | const className = 'bounce-in' 7 | const initClassName = ['scale-85', 'opacity-0'] 8 | 9 | export function useBounceOnVisible(el: Ref = useCurrentElement()) { 10 | if (!isMiddleScreen.value || !scrollAnimationEffect.value) return 11 | 12 | const visible = useElementVisibility(el) 13 | 14 | onMounted(() => { 15 | if (!el.value) return 16 | 17 | el.value.classList.add(...initClassName) 18 | 19 | watch( 20 | visible, 21 | (value) => { 22 | if (!el.value) return 23 | 24 | if (value) { 25 | el.value.classList.add(className) 26 | el.value.classList.remove(...initClassName) 27 | } else { 28 | el.value.classList.remove(className) 29 | el.value.classList.add(...initClassName) 30 | } 31 | }, 32 | { immediate: true }, 33 | ) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /src/components/sidebar/ConnectionTabs.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 35 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/sidebar/OverviewCtrl.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 33 | -------------------------------------------------------------------------------- /public/favicon-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/composables/keyboard.ts: -------------------------------------------------------------------------------- 1 | import { renderRoutes } from '@/helper' 2 | import { activeBackend } from '@/store/setup' 3 | import { computed, onMounted, onUnmounted } from 'vue' 4 | import { useRouter } from 'vue-router' 5 | 6 | export const useKeyboard = () => { 7 | const router = useRouter() 8 | 9 | const routeShortcuts = computed(() => { 10 | return renderRoutes.value.map((route, index) => ({ 11 | key: (index + 1).toString(), 12 | route, 13 | })) 14 | }) 15 | 16 | const handleKeydown = (event: KeyboardEvent) => { 17 | if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { 18 | return 19 | } 20 | 21 | if (!activeBackend.value || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { 22 | return 23 | } 24 | 25 | const key = event.key 26 | const route = routeShortcuts.value.find((s) => s.key === key) 27 | if (route) { 28 | event.preventDefault() 29 | router.push({ name: route.route }) 30 | } 31 | } 32 | 33 | onMounted(() => { 34 | document.addEventListener('keydown', handleKeydown) 35 | }) 36 | 37 | onUnmounted(() => { 38 | document.removeEventListener('keydown', handleKeydown) 39 | }) 40 | 41 | return { 42 | routeShortcuts, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/components/overview/ConnectionsCharts.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 47 | -------------------------------------------------------------------------------- /src/views/ConnectionsPage.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 29 | 30 | 45 | -------------------------------------------------------------------------------- /src/components/overview/SpeedCharts.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 51 | -------------------------------------------------------------------------------- /src/components/settings/OverviewCard.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 39 | -------------------------------------------------------------------------------- /src/views/OverviewPage.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 45 | -------------------------------------------------------------------------------- /src/components/logs/LogsCard.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 48 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | zashboard 6 | 10 | 14 | 18 | 23 | 30 | 34 | 35 | 36 |
37 | 51 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/components/sidebar/VerticalInfos.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 39 | -------------------------------------------------------------------------------- /src/assets/theme.css: -------------------------------------------------------------------------------- 1 | @plugin "daisyui/theme" { 2 | name: 'dark-legacy'; 3 | --color-primary: oklch(65.69% 0.196 275.75); 4 | --color-primary-content: #050617; 5 | --color-secondary: oklch(74.8% 0.26 342.55); 6 | --color-secondary-content: #190211; 7 | --color-accent: oklch(74.51% 0.167 183.61); 8 | --color-accent-content: #000e0c; 9 | --color-neutral: #2a323c; 10 | --color-neutral-content: #a6adbb; 11 | --color-base-100: #1d232a; 12 | --color-base-200: #191e24; 13 | --color-base-300: #15191e; 14 | --color-base-content: #a6adbb; 15 | --radius-selector: 1rem; 16 | --radius-field: 0.5rem; 17 | --radius-box: 1rem; 18 | --size-selector: 0.25rem; 19 | --size-field: 0.25rem; 20 | --border: 1px; 21 | --depth: 0; 22 | --noise: 0; 23 | color-scheme: dark; 24 | } 25 | 26 | @plugin "daisyui/theme" { 27 | name: 'light-legacy'; 28 | --color-primary: oklch(49.12% 0.3096 275.75); 29 | --color-primary-content: #d4dbff; 30 | --color-secondary: oklch(69.71% 0.329 342.55); 31 | --color-secondary-content: oklch(98.71% 0.0106 342.55); 32 | --color-accent: oklch(76.76% 0.184 183.61); 33 | --color-accent-content: #00100d; 34 | --color-neutral: #2b3440; 35 | --color-neutral-content: #d7dde4; 36 | --color-base-100: oklch(100% 0 0); 37 | --color-base-200: #f2f2f2; 38 | --color-base-300: #e5e6e6; 39 | --color-base-content: #1f2937; 40 | --radius-selector: 1rem; 41 | --radius-field: 0.5rem; 42 | --radius-box: 1rem; 43 | --size-selector: 0.25rem; 44 | --size-field: 0.25rem; 45 | --border: 1px; 46 | --depth: 0; 47 | --noise: 0; 48 | } 49 | -------------------------------------------------------------------------------- /src/components/common/CollapseCard.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 60 | -------------------------------------------------------------------------------- /src/composables/proxiesScroll.ts: -------------------------------------------------------------------------------- 1 | import { PROXY_CARD_SIZE } from '@/constant' 2 | import { findScrollableParent } from '@/helper/utils' 3 | import { minProxyCardWidth, proxyCardSize } from '@/store/settings' 4 | import { useCurrentElement, useElementSize, useInfiniteScroll } from '@vueuse/core' 5 | import { computed, nextTick, onMounted, ref, watch } from 'vue' 6 | 7 | export const useCalculateMaxProxies = (totalProxies: number, activeIndex: number) => { 8 | const el = useCurrentElement() 9 | const { width } = useElementSize(el) 10 | const initMaxProxies = computed(() => { 11 | return ( 12 | Math.max(Math.floor(width.value / minProxyCardWidth.value), 2) * 13 | (proxyCardSize.value === PROXY_CARD_SIZE.LARGE ? 9 : 12) 14 | ) 15 | }) 16 | const maxProxies = ref(Math.max(24, activeIndex + 12)) 17 | 18 | onMounted(() => { 19 | watch( 20 | initMaxProxies, 21 | () => { 22 | maxProxies.value = Math.max(maxProxies.value, initMaxProxies.value) 23 | }, 24 | { immediate: true }, 25 | ) 26 | 27 | nextTick(() => { 28 | const scrollEl = findScrollableParent(el.value as HTMLElement) 29 | 30 | useInfiniteScroll( 31 | scrollEl, 32 | () => { 33 | maxProxies.value = Math.min((maxProxies.value += initMaxProxies.value), totalProxies) 34 | }, 35 | { 36 | distance: 100, 37 | canLoadMore: () => { 38 | return maxProxies.value < totalProxies 39 | }, 40 | }, 41 | ) 42 | }) 43 | }) 44 | 45 | return { 46 | maxProxies, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/assets/load-fonts.ts: -------------------------------------------------------------------------------- 1 | export const loadFonts = () => { 2 | if (import.meta.env.MODE === 'cdn-fonts') { 3 | const createLink = (href: string) => { 4 | const link = document.createElement('link') 5 | link.rel = 'stylesheet' 6 | link.href = href 7 | link.media = 'print' 8 | link.onload = () => { 9 | link.media = 'all' 10 | } 11 | document.head.appendChild(link) 12 | } 13 | 14 | createLink('https://unpkg.com/subsetted-fonts@latest/MiSans-VF/MiSans-VF.css') 15 | createLink('https://unpkg.com/subsetted-fonts@latest/SarasaUiSC-Regular/SarasaUiSC-Regular.css') 16 | createLink('https://unpkg.com/subsetted-fonts@latest/PingFangSC-Regular/PingFangSC-Regular.css') 17 | createLink('https://unpkg.com/@fontsource/fira-sans') 18 | } else if (import.meta.env.MODE === 'MiSans') { 19 | import('subsetted-fonts/MiSans-VF/MiSans-VF.css') 20 | } else if (import.meta.env.MODE === 'SarasaUi') { 21 | import('subsetted-fonts/SarasaUiSC-Regular/SarasaUiSC-Regular.css') 22 | } else if (import.meta.env.MODE === 'PingFang') { 23 | import('subsetted-fonts/PingFangSC-Regular/PingFangSC-Regular.css') 24 | } else if (import.meta.env.MODE === 'FiraSans') { 25 | import('@fontsource/fira-sans/index.css') 26 | } else if (import.meta.env.MODE === 'SystemUI') { 27 | } else { 28 | import('@fontsource/fira-sans/index.css') 29 | import('subsetted-fonts/MiSans-VF/MiSans-VF.css') 30 | import('subsetted-fonts/SarasaUiSC-Regular/SarasaUiSC-Regular.css') 31 | import('subsetted-fonts/PingFangSC-Regular/PingFangSC-Regular.css') 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/composables/proxies.ts: -------------------------------------------------------------------------------- 1 | import { isSingBox } from '@/api' 2 | import { GLOBAL, PROXY_TAB_TYPE } from '@/constant' 3 | import { isHiddenGroup } from '@/helper' 4 | import { configs } from '@/store/config' 5 | import { proxiesTabShow, proxyGroupList, proxyMap, proxyProviederList } from '@/store/proxies' 6 | import { customGlobalNode, displayGlobalByMode, manageHiddenGroup } from '@/store/settings' 7 | import { isEmpty } from 'lodash' 8 | import { computed, ref } from 'vue' 9 | 10 | const filterGroups = (all: string[]) => { 11 | if (manageHiddenGroup.value) { 12 | return all 13 | } 14 | 15 | return all.filter((name) => !isHiddenGroup(name)) 16 | } 17 | 18 | const getRenderGroups = () => { 19 | if (isEmpty(proxyMap.value)) { 20 | return [] 21 | } 22 | 23 | if (proxiesTabShow.value === PROXY_TAB_TYPE.PROVIDER) { 24 | return proxyProviederList.value.map((group) => group.name) 25 | } 26 | 27 | if (displayGlobalByMode.value) { 28 | if (configs.value?.mode.toUpperCase() === GLOBAL) { 29 | return [ 30 | isSingBox.value && proxyMap.value[customGlobalNode.value] ? customGlobalNode.value : GLOBAL, 31 | ] 32 | } 33 | 34 | return filterGroups(proxyGroupList.value) 35 | } 36 | 37 | return filterGroups([...proxyGroupList.value, GLOBAL]) 38 | } 39 | 40 | export const disableProxiesPageScroll = ref(false) 41 | export const isProxiesPageMounted = ref(false) 42 | export const renderGroups = computed(() => { 43 | const groups = getRenderGroups() 44 | 45 | if (isProxiesPageMounted.value) { 46 | return groups 47 | } 48 | 49 | return groups.slice(0, 16) 50 | }) 51 | -------------------------------------------------------------------------------- /src/store/smart.ts: -------------------------------------------------------------------------------- 1 | import { fetchSmartGroupWeightsAPI, fetchSmartWeightsAPI } from '@/api' 2 | import type { NodeRank } from '@/types' 3 | import { ref } from 'vue' 4 | 5 | export const smartWeightsMap = ref>>({}) 6 | export const smartOrderMap = ref>>({}) 7 | 8 | const restructWeights = (proxyName: string, weights: NodeRank[]) => { 9 | const smartWeights: Record = {} 10 | const smartOrder: Record = {} 11 | 12 | weights.forEach((weight, index) => { 13 | smartWeights[weight.Name] = weight.Rank 14 | smartOrder[weight.Name] = index 15 | }) 16 | 17 | smartWeightsMap.value[proxyName] = smartWeights 18 | smartOrderMap.value[proxyName] = smartOrder 19 | } 20 | 21 | // deprecated 22 | const fetchSmartGroupWeights = async (proxyName: string) => { 23 | const { data } = await fetchSmartGroupWeightsAPI(proxyName) 24 | 25 | if (!data.weights?.length) return 26 | 27 | restructWeights(proxyName, data.weights) 28 | } 29 | 30 | export const initSmartWeights = async (smartGroups: string[]) => { 31 | const { status, data: smartWeights } = await fetchSmartWeightsAPI() 32 | 33 | smartWeightsMap.value = {} 34 | smartOrderMap.value = {} 35 | 36 | if (status !== 200) { 37 | // deprecated fallback 38 | smartGroups.forEach((name) => { 39 | fetchSmartGroupWeights(name) 40 | }) 41 | return 42 | } 43 | 44 | for (const [group, weights] of Object.entries(smartWeights.weights)) { 45 | if (!weights?.length) continue 46 | 47 | restructWeights(group, weights) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/store/setup.ts: -------------------------------------------------------------------------------- 1 | import type { Backend } from '@/types' 2 | import { useStorage } from '@vueuse/core' 3 | import { isEqual, omit } from 'lodash' 4 | import { v4 as uuid } from 'uuid' 5 | import { computed } from 'vue' 6 | import { sourceIPLabelList } from './settings' 7 | 8 | export const backendList = useStorage('setup/api-list', []) 9 | export const activeUuid = useStorage('setup/active-uuid', '') 10 | export const activeBackend = computed(() => 11 | backendList.value.find((backend) => backend.uuid === activeUuid.value), 12 | ) 13 | 14 | export const addBackend = (backend: Omit) => { 15 | const currentEnd = backendList.value.find((end) => { 16 | return isEqual(omit(end, 'uuid'), backend) 17 | }) 18 | 19 | if (currentEnd) { 20 | activeUuid.value = currentEnd.uuid 21 | return 22 | } 23 | 24 | const id = uuid() 25 | 26 | backendList.value.push({ 27 | ...backend, 28 | uuid: id, 29 | }) 30 | activeUuid.value = id 31 | } 32 | 33 | export const updateBackend = (uuid: string, backend: Omit) => { 34 | const index = backendList.value.findIndex((end) => end.uuid === uuid) 35 | if (index !== -1) { 36 | backendList.value[index] = { 37 | ...backend, 38 | uuid, 39 | } 40 | } 41 | } 42 | 43 | export const removeBackend = (uuid: string) => { 44 | backendList.value = backendList.value.filter((end) => end.uuid !== uuid) 45 | sourceIPLabelList.value.forEach((label) => { 46 | if (label.scope && label.scope.includes(uuid)) { 47 | label.scope = label.scope.filter((scope) => scope !== uuid) 48 | if (!label.scope.length) { 49 | delete label.scope 50 | } 51 | } 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /src/components/common/DialogWrapper.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 60 | -------------------------------------------------------------------------------- /src/components/settings/TableSettings.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 55 | -------------------------------------------------------------------------------- /src/views/LogsPage.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 58 | -------------------------------------------------------------------------------- /src/components/sidebar/SidebarButtons.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 57 | -------------------------------------------------------------------------------- /src/components/settings/BackendSwitch.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 74 | -------------------------------------------------------------------------------- /src/store/rules.ts: -------------------------------------------------------------------------------- 1 | import { fetchRuleProvidersAPI, fetchRulesAPI } from '@/api' 2 | import { RULE_TAB_TYPE } from '@/constant' 3 | import type { Rule, RuleProvider } from '@/types' 4 | import { computed, ref } from 'vue' 5 | 6 | export const rulesFilter = ref('') 7 | export const rulesTabShow = ref(RULE_TAB_TYPE.RULES) 8 | 9 | export const rules = ref([]) 10 | export const ruleProviderList = ref([]) 11 | 12 | export const renderRules = computed(() => { 13 | const rulesFilterValue = rulesFilter.value.split(' ').map((f) => f.toLowerCase().trim()) 14 | 15 | if (rulesFilter.value === '') { 16 | return rules.value 17 | } 18 | 19 | return rules.value.filter((rule) => { 20 | return rulesFilterValue.every((f) => 21 | [rule.type.toLowerCase(), rule.payload.toLowerCase(), rule.proxy.toLowerCase()].some((i) => 22 | i.includes(f), 23 | ), 24 | ) 25 | }) 26 | }) 27 | 28 | export const renderRulesProvider = computed(() => { 29 | const rulesFilterValue = rulesFilter.value.split(' ').map((f) => f.toLowerCase().trim()) 30 | 31 | if (rulesFilter.value === '') { 32 | return ruleProviderList.value 33 | } 34 | 35 | return ruleProviderList.value.filter((ruleProvider) => { 36 | return rulesFilterValue.every((f) => 37 | [ 38 | ruleProvider.name.toLowerCase(), 39 | ruleProvider.behavior.toLowerCase(), 40 | ruleProvider.vehicleType.toLowerCase(), 41 | ].some((i) => i.includes(f)), 42 | ) 43 | }) 44 | }) 45 | 46 | export const fetchRules = async () => { 47 | const { data: ruleData } = await fetchRulesAPI() 48 | const { data: providerData } = await fetchRuleProvidersAPI() 49 | 50 | rules.value = ruleData.rules.map((rule) => { 51 | const proxy = rule.proxy 52 | const proxyName = proxy.startsWith('route(') ? proxy.substring(6, proxy.length - 1) : proxy 53 | 54 | return { 55 | ...rule, 56 | proxy: proxyName, 57 | } 58 | }) 59 | ruleProviderList.value = Object.values(providerData.providers) 60 | } 61 | -------------------------------------------------------------------------------- /src/components/settings/ThemeSelector.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 70 | -------------------------------------------------------------------------------- /src/helper/sourceip.ts: -------------------------------------------------------------------------------- 1 | import { sourceIPLabelList } from '@/store/settings' 2 | import { activeBackend } from '@/store/setup' 3 | import { watch } from 'vue' 4 | 5 | const CACHE_SIZE = 256 6 | const ipLabelCache = new Map() 7 | const sourceIPMap = new Map() 8 | const sourceIPRegexList: { regex: RegExp; label: string }[] = [] 9 | 10 | const preprocessSourceIPList = () => { 11 | ipLabelCache.clear() 12 | sourceIPMap.clear() 13 | sourceIPRegexList.length = 0 14 | 15 | for (const { key, label, scope } of sourceIPLabelList.value) { 16 | if (scope && !scope.includes(activeBackend.value?.uuid as string)) continue 17 | if (key.startsWith('/')) { 18 | sourceIPRegexList.push({ regex: new RegExp(key.slice(1), 'i'), label }) 19 | } else { 20 | sourceIPMap.set(key, label) 21 | } 22 | } 23 | } 24 | 25 | const cacheResult = (ip: string, label: string) => { 26 | ipLabelCache.set(ip, label) 27 | 28 | if (ipLabelCache.size > CACHE_SIZE) { 29 | const firstKey = ipLabelCache.keys().next().value 30 | 31 | if (firstKey) { 32 | ipLabelCache.delete(firstKey) 33 | } 34 | } 35 | 36 | return label 37 | } 38 | 39 | watch(() => [sourceIPLabelList.value, activeBackend.value], preprocessSourceIPList, { 40 | immediate: true, 41 | deep: true, 42 | }) 43 | 44 | export const getIPLabelFromMap = (ip: string) => { 45 | if (!ip) return ip === '' ? 'Inner' : '' 46 | 47 | if (ipLabelCache.has(ip)) { 48 | return ipLabelCache.get(ip)! 49 | } 50 | 51 | const isIPv6 = ip.includes(':') 52 | 53 | if (isIPv6) { 54 | for (const [key, label] of sourceIPMap.entries()) { 55 | if (ip.endsWith(key)) { 56 | return cacheResult(ip, label) 57 | } 58 | } 59 | } else if (sourceIPMap.has(ip)) { 60 | return cacheResult(ip, sourceIPMap.get(ip)!) 61 | } 62 | 63 | for (const { regex, label } of sourceIPRegexList) { 64 | if (regex.test(ip)) { 65 | return cacheResult(ip, label) 66 | } 67 | } 68 | 69 | return cacheResult(ip, ip) 70 | } 71 | -------------------------------------------------------------------------------- /src/components/rules/RuleProvider.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 59 | -------------------------------------------------------------------------------- /src/components/overview/OverviewCardSettingsDialog.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 58 | 59 | 64 | -------------------------------------------------------------------------------- /src/helper/autoImportSettings.ts: -------------------------------------------------------------------------------- 1 | import { showNotification } from '@/helper/notification' 2 | import { useStorage } from '@vueuse/core' 3 | const IMPORT_SETTINGS_URL_KEY = 'config/import-settings-url' 4 | 5 | export const DEFAULT_SETTINGS_URL = './zashboard-settings.json' 6 | export const importSettingsUrl = useStorage(IMPORT_SETTINGS_URL_KEY, DEFAULT_SETTINGS_URL) 7 | export const autoImportSettings = useStorage('config/auto-import-settings', false) 8 | 9 | const autoImportSettingsHash = useStorage('cache/auto-import-settings-hash', '') 10 | const calculateSettingsHash = async (settings: Record) => { 11 | const sortedKeys = Object.keys(settings).sort() 12 | const hashString = sortedKeys.map((key) => `${key}:${settings[key]}`).join('|') 13 | 14 | let hash = 0 15 | for (let i = 0; i < hashString.length; i++) { 16 | const char = hashString.charCodeAt(i) 17 | hash = (hash << 5) - hash + char 18 | hash = hash & hash 19 | } 20 | return Math.abs(hash).toString(16).padStart(8, '0') 21 | } 22 | export const importSettingsFromUrl = async (force = false) => { 23 | const res = await fetch(importSettingsUrl.value) 24 | const errorHandler = () => { 25 | showNotification({ 26 | content: 'importFailed', 27 | params: { url: res.url }, 28 | type: 'alert-error', 29 | }) 30 | } 31 | if (!res.ok) { 32 | errorHandler() 33 | return 34 | } 35 | let settings: Record = {} 36 | try { 37 | settings = await res.json() 38 | } catch { 39 | errorHandler() 40 | return 41 | } 42 | 43 | if (!settings) { 44 | errorHandler() 45 | return 46 | } 47 | 48 | const newHash = await calculateSettingsHash(settings) 49 | 50 | if (newHash === autoImportSettingsHash.value && !force) { 51 | return 52 | } 53 | 54 | showNotification({ 55 | content: 'importing', 56 | }) 57 | autoImportSettingsHash.value = newHash 58 | 59 | for (const key in settings) { 60 | if (key === IMPORT_SETTINGS_URL_KEY && !settings[key]) { 61 | continue 62 | } 63 | localStorage.setItem(key, settings[key] as string) 64 | } 65 | location.reload() 66 | } 67 | -------------------------------------------------------------------------------- /src/helper/tooltip.ts: -------------------------------------------------------------------------------- 1 | import tippy, { type Instance, type Props } from 'tippy.js' 2 | 3 | let appContent: HTMLElement 4 | let tippyInstance: Instance | null = null 5 | let currentTarget: HTMLElement | null = null 6 | 7 | export const useTooltip = () => { 8 | if (!appContent) { 9 | appContent = document.getElementById('app-content')! 10 | } 11 | 12 | const showTip = (event: Event, content: string | HTMLElement, config: Partial = {}) => { 13 | if (currentTarget === event.currentTarget) { 14 | return 15 | } 16 | 17 | tippyInstance?.destroy() 18 | tippyInstance = tippy(event.currentTarget as HTMLElement, { 19 | content, 20 | placement: 'top', 21 | animation: 'scale', 22 | appendTo: appContent, 23 | allowHTML: true, 24 | showOnCreate: true, 25 | onHidden: () => { 26 | tippyInstance?.destroy() 27 | tippyInstance = null 28 | currentTarget = null 29 | }, 30 | popperOptions: { 31 | modifiers: [ 32 | { 33 | name: 'preventOverflow', 34 | options: { 35 | boundary: 'clippingParents', 36 | padding: 8, 37 | }, 38 | }, 39 | { 40 | name: 'flip', 41 | options: { 42 | fallbackPlacements: ['top', 'bottom', 'right', 'left'], 43 | }, 44 | }, 45 | ], 46 | }, 47 | ...config, 48 | }) 49 | 50 | currentTarget = event.currentTarget as HTMLElement 51 | } 52 | 53 | const hideTip = () => { 54 | tippyInstance?.hide() 55 | } 56 | 57 | const updateTip = (content: string | HTMLElement) => { 58 | tippyInstance?.setContent(content) 59 | } 60 | 61 | return { 62 | showTip, 63 | hideTip, 64 | updateTip, 65 | } 66 | } 67 | 68 | const { showTip } = useTooltip() 69 | 70 | export const checkTruncation = (e: Event) => { 71 | const target = e.target as HTMLElement 72 | const { scrollWidth, clientWidth } = target 73 | 74 | if (scrollWidth > clientWidth) { 75 | showTip(e, target.innerText, { 76 | delay: [700, 0], 77 | trigger: 'mouseenter', 78 | touch: ['hold', 500], 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/components/proxies/ProxyGroupNow.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 71 | -------------------------------------------------------------------------------- /src/views/RulesPage.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 67 | -------------------------------------------------------------------------------- /src/components/overview/StatisticsStats.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 73 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import vue from '@vitejs/plugin-vue' 2 | import vueJsx from '@vitejs/plugin-vue-jsx' 3 | import { execSync } from 'child_process' 4 | import { fileURLToPath, URL } from 'node:url' 5 | import { defineConfig } from 'vite' 6 | import { VitePWA } from 'vite-plugin-pwa' 7 | import { version } from './package.json' 8 | 9 | const getGitCommitId = (): string => { 10 | try { 11 | const commitMessage = execSync('git log -1 --pretty=%B', { encoding: 'utf8' }).trim() 12 | 13 | if (commitMessage.includes('chore(main): release')) { 14 | return '' 15 | } 16 | 17 | return execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim() 18 | } catch (error) { 19 | console.warn('无法获取git commit ID:', error) 20 | return '' 21 | } 22 | } 23 | 24 | // https://vite.dev/config/ 25 | export default defineConfig({ 26 | define: { 27 | __APP_VERSION__: JSON.stringify(version), 28 | __COMMIT_ID__: JSON.stringify(getGitCommitId()), 29 | }, 30 | base: './', 31 | plugins: [ 32 | vue(), 33 | vueJsx(), 34 | VitePWA({ 35 | registerType: 'autoUpdate', 36 | includeAssets: ['favicon.svg', 'favicon-dark.svg'], 37 | manifest: { 38 | name: 'zashboard', 39 | short_name: 'zashboard', 40 | description: 'a dashboard using clash api', 41 | theme_color: '#000000', 42 | icons: [ 43 | { 44 | src: './pwa-192x192.png', 45 | sizes: '192x192', 46 | type: 'image/png', 47 | purpose: 'any', 48 | }, 49 | { 50 | src: './pwa-512x512.png', 51 | sizes: '512x512', 52 | type: 'image/png', 53 | purpose: 'any', 54 | }, 55 | { 56 | src: './pwa-maskable-192x192.png', 57 | sizes: '192x192', 58 | type: 'image/png', 59 | purpose: 'maskable', 60 | }, 61 | { 62 | src: './pwa-maskable-512x512.png', 63 | sizes: '512x512', 64 | type: 'image/png', 65 | purpose: 'maskable', 66 | }, 67 | ], 68 | }, 69 | }), 70 | ], 71 | resolve: { 72 | alias: { 73 | '@': fileURLToPath(new URL('./src', import.meta.url)), 74 | }, 75 | }, 76 | }) 77 | -------------------------------------------------------------------------------- /src/components/settings/DnsQuery.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 82 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-缺陷报告.yml: -------------------------------------------------------------------------------- 1 | name: 缺陷报告 2 | description: 创建一个报告来帮助我们改进 3 | title: '[Bug]: ' 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | 感谢您报告bug!在提交之前,请搜索[现有issues](https://github.com/Zephyruso/zashboard/issues)看看您的问题是否已经被报告过。 9 | 10 | - type: input 11 | id: zashboard-version 12 | attributes: 13 | label: zashboard版本 14 | description: 您使用的zashboard版本(例如:1.81.0 或 gh-pages commit hash) 15 | placeholder: '例如:1.81.0' 16 | validations: 17 | required: true 18 | 19 | - type: input 20 | id: system-info 21 | attributes: 22 | label: 系统信息 23 | description: 您的操作系统和版本 24 | placeholder: '例如:Windows 10, macOS Catalina 10.15.7, Ubuntu 20.04' 25 | validations: 26 | required: true 27 | 28 | - type: input 29 | id: zashboard-usage 30 | attributes: 31 | label: zashboard 使用方式 32 | description: 您使用zashboard的方式 (e.g., sing-box cli, mihomo cli, openwrt插件如Nikki, Docker) 33 | placeholder: 'e.g., sing-box cli, mihomo cli, openwrt插件如Nikki, Docker' 34 | validations: 35 | required: true 36 | 37 | - type: input 38 | id: browser-info 39 | attributes: 40 | label: 浏览器信息 41 | description: 您的浏览器和版本 42 | placeholder: '例如:Chrome 91.0, Firefox 89.0, Safari 14.1' 43 | validations: 44 | required: true 45 | 46 | - type: textarea 47 | id: steps-to-reproduce 48 | attributes: 49 | label: 重现步骤 50 | description: 重现这个bug的详细步骤 51 | placeholder: | 52 | 1. 第一步 53 | 2. 第二步 54 | 3. 第三步 55 | validations: 56 | required: true 57 | 58 | - type: textarea 59 | id: expected-behavior 60 | attributes: 61 | label: 期望行为 62 | description: 您期望发生的事情 63 | placeholder: '描述您期望看到的行为' 64 | validations: 65 | required: true 66 | 67 | - type: textarea 68 | id: actual-behavior 69 | attributes: 70 | label: 实际行为 71 | description: 实际发生的事情 72 | placeholder: '描述实际发生的行为' 73 | validations: 74 | required: true 75 | 76 | - type: textarea 77 | id: screenshot 78 | attributes: 79 | label: Bug截图 80 | description: 如果有的话,请粘贴截图到这里 81 | placeholder: '粘贴截图到这里' 82 | validations: 83 | required: false 84 | 85 | - type: textarea 86 | id: additional-context 87 | attributes: 88 | label: 额外信息 89 | description: 任何其他可能有助于解决此问题的上下文或信息 90 | placeholder: '其他相关信息' 91 | validations: 92 | required: false 93 | -------------------------------------------------------------------------------- /src/components/overview/ConnectionStatus.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 83 | -------------------------------------------------------------------------------- /src/components/settings/UpgradeCoreModal.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 80 | -------------------------------------------------------------------------------- /src/components/sidebar/SideBar.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 76 | -------------------------------------------------------------------------------- /src/store/logs.ts: -------------------------------------------------------------------------------- 1 | import { fetchLogsAPI } from '@/api' 2 | import { LOG_LEVEL } from '@/constant' 3 | import type { Log, LogWithSeq } from '@/types' 4 | import { useStorage } from '@vueuse/core' 5 | import dayjs from 'dayjs' 6 | import { throttle } from 'lodash' 7 | import { ref, watch } from 'vue' 8 | import { logRetentionLimit, sourceIPLabelList } from './settings' 9 | import { activeBackend } from './setup' 10 | 11 | export const logs = ref([]) 12 | export const logFilter = ref('') 13 | export const logTypeFilter = ref('') 14 | export const isPaused = ref(false) 15 | export const logLevel = useStorage('config/log-level', LOG_LEVEL.Info) 16 | export const logFilterRegex = useStorage('config/log-filter-regex', '') 17 | export const logFilterEnabled = useStorage('config/log-filter-enabled', false) 18 | 19 | let cancel: () => void 20 | let logsTemp: LogWithSeq[] = [] 21 | 22 | const sliceLogs = throttle(() => { 23 | logs.value = logsTemp.concat(logs.value).slice(0, logRetentionLimit.value) 24 | logsTemp = [] 25 | }, 500) 26 | 27 | const ipSourceMatchs: [RegExp, string][] = [] 28 | const restructMatchs = () => { 29 | ipSourceMatchs.length = 0 30 | for (const { key, label, scope } of sourceIPLabelList.value) { 31 | if (scope && !scope.includes(activeBackend.value?.uuid as string)) continue 32 | if (key.startsWith('/')) continue 33 | 34 | if (key.includes(':')) { 35 | const regex = new RegExp(`${key}]:`, 'ig') 36 | ipSourceMatchs.push([regex, `${key}] (${label}) :`]) 37 | } else { 38 | const regex = new RegExp(`${key}:`, 'ig') 39 | ipSourceMatchs.push([regex, `${key} (${label}) :`]) 40 | } 41 | } 42 | } 43 | 44 | watch( 45 | () => [sourceIPLabelList.value, activeBackend.value], 46 | () => { 47 | restructMatchs() 48 | }, 49 | { 50 | immediate: true, 51 | deep: true, 52 | }, 53 | ) 54 | 55 | export const initLogs = () => { 56 | cancel?.() 57 | logs.value = [] 58 | logsTemp = [] 59 | 60 | let idx = 1 61 | const ws = fetchLogsAPI({ 62 | level: logLevel.value, 63 | }) 64 | 65 | const unwatch = watch(ws.data, (data) => { 66 | if (!data) return 67 | 68 | if (isPaused.value) { 69 | idx++ 70 | return 71 | } 72 | 73 | for (const [regex, label] of ipSourceMatchs) { 74 | data.payload = data.payload.replace(regex, label) 75 | } 76 | 77 | logsTemp.unshift({ 78 | ...data, 79 | time: dayjs().format('HH:mm:ss'), 80 | seq: idx++, 81 | }) 82 | 83 | sliceLogs() 84 | }) 85 | 86 | cancel = () => { 87 | unwatch() 88 | ws.close() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/components/common/VirtualScroller.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 95 | -------------------------------------------------------------------------------- /src/store/overview.ts: -------------------------------------------------------------------------------- 1 | import { fetchMemoryAPI, fetchTrafficAPI } from '@/api' 2 | import { ref, watch } from 'vue' 3 | import { activeConnections } from './connections' 4 | 5 | export const timeSaved = 60 6 | const initValue = new Array(timeSaved).fill(0).map((v, i) => ({ name: i, value: v })) 7 | 8 | export const memory = ref(0) 9 | export const memoryHistory = ref([...initValue]) 10 | export const connectionsHistory = ref([...initValue]) 11 | 12 | export const downloadSpeed = ref(0) 13 | export const uploadSpeed = ref(0) 14 | export const downloadSpeedHistory = ref([...initValue]) 15 | export const uploadSpeedHistory = ref([...initValue]) 16 | 17 | let cancel: () => void 18 | 19 | export const initSatistic = () => { 20 | cancel?.() 21 | 22 | downloadSpeedHistory.value = [...initValue] 23 | uploadSpeedHistory.value = [...initValue] 24 | memoryHistory.value = [...initValue] 25 | 26 | const { data: memoryWsData, close: memoryWsClose } = fetchMemoryAPI<{ 27 | inuse: number 28 | }>() 29 | const unwatchMemory = watch( 30 | () => memoryWsData.value, 31 | (data) => { 32 | if (!data) return 33 | const timestamp = Date.now().valueOf() 34 | 35 | if (data.inuse === 0) { 36 | return 37 | } 38 | 39 | memory.value = data.inuse 40 | memoryHistory.value.push({ 41 | value: data.inuse, 42 | name: timestamp, 43 | }) 44 | connectionsHistory.value.push({ 45 | value: activeConnections.value.length, 46 | name: timestamp, 47 | }) 48 | 49 | memoryHistory.value = memoryHistory.value.slice(-1 * timeSaved) 50 | connectionsHistory.value = connectionsHistory.value.slice(-1 * timeSaved) 51 | }, 52 | ) 53 | 54 | const { data: trafficWsData, close: trafficWsClose } = fetchTrafficAPI<{ 55 | down: number 56 | up: number 57 | }>() 58 | const unwatchTraffic = watch( 59 | () => trafficWsData.value, 60 | (data) => { 61 | if (!data) return 62 | 63 | const timestamp = Date.now().valueOf() 64 | 65 | downloadSpeed.value = data.down 66 | uploadSpeed.value = data.up 67 | 68 | downloadSpeedHistory.value.push({ 69 | value: data.down, 70 | name: timestamp, 71 | }) 72 | uploadSpeedHistory.value.push({ 73 | value: data.up, 74 | name: timestamp, 75 | }) 76 | 77 | downloadSpeedHistory.value = downloadSpeedHistory.value.slice(-1 * timeSaved) 78 | uploadSpeedHistory.value = uploadSpeedHistory.value.slice(-1 * timeSaved) 79 | }, 80 | ) 81 | 82 | cancel = () => { 83 | memoryWsClose() 84 | trafficWsClose() 85 | unwatchMemory() 86 | unwatchTraffic() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/components/sidebar/SourceIPFilter.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zashboard", 3 | "version": "2.4.1", 4 | "description": "A Dashboard Using Clash API", 5 | "license": "MIT", 6 | "type": "module", 7 | "scripts": { 8 | "build": "vite build", 9 | "build:cdn-fonts": "vite build --mode cdn-fonts", 10 | "build:firasans-only": "vite build --mode FiraSans", 11 | "build:misans-only": "vite build --mode MiSans", 12 | "build:no-fonts": "vite build --mode SystemUI", 13 | "build:pingfang-only": "vite build --mode PingFang", 14 | "build:sarasa-only": "vite build --mode SarasaUi", 15 | "dev": "vite", 16 | "format": "prettier --write src/", 17 | "lint": "eslint . --fix", 18 | "prepare": "husky", 19 | "preview": "vite preview", 20 | "type-check": "vue-tsc --build --force" 21 | }, 22 | "dependencies": { 23 | "@eslint/plugin-kit": "^0.4.1", 24 | "@fontsource/fira-sans": "^5.2.7", 25 | "@heroicons/vue": "^2.2.0", 26 | "@tanstack/vue-table": "^8.21.3", 27 | "@tanstack/vue-virtual": "^3.13.12", 28 | "@types/reconnectingwebsocket": "^1.0.10", 29 | "@vueuse/core": "^14.0.0", 30 | "axios": "^1.13.2", 31 | "countup.js": "^2.9.0", 32 | "dayjs": "^1.11.19", 33 | "dompurify": "^3.3.0", 34 | "echarts": "^6.0.0", 35 | "ipaddr.js": "^2.2.0", 36 | "lodash": "^4.17.21", 37 | "misans": "^4.1.0", 38 | "p-limit": "^7.2.0", 39 | "prettier-plugin-organize-imports": "^4.3.0", 40 | "prettier-plugin-tailwindcss": "^0.7.1", 41 | "pretty-bytes": "^7.1.0", 42 | "reconnectingwebsocket": "^1.0.0", 43 | "sort-package-json": "^3.4.0", 44 | "subsetted-fonts": "^1.0.4", 45 | "tippy.js": "^6.3.7", 46 | "uuid": "^13.0.0", 47 | "vite-plugin-pwa": "^1.1.0", 48 | "vue": "^3.5.24", 49 | "vue-i18n": "^11.1.12", 50 | "vue-json-pretty": "^2.6.0", 51 | "vue-router": "^4.6.3", 52 | "vuedraggable": "^4.1.0" 53 | }, 54 | "devDependencies": { 55 | "@tailwindcss/postcss": "^4.1.17", 56 | "@tsconfig/node22": "^22.0.2", 57 | "@types/lodash": "^4.17.20", 58 | "@types/node": "^24.10.0", 59 | "@vitejs/plugin-vue": "^6.0.1", 60 | "@vitejs/plugin-vue-jsx": "^5.1.1", 61 | "@vue/eslint-config-prettier": "^10.2.0", 62 | "@vue/eslint-config-typescript": "^14.6.0", 63 | "@vue/tsconfig": "^0.8.1", 64 | "daisyui": "^5.2.5", 65 | "eslint": "^9.39.1", 66 | "eslint-plugin-vue": "^10.5.1", 67 | "husky": "^9.1.7", 68 | "lint-staged": "^16.2.6", 69 | "postcss": "^8.5.6", 70 | "postcss-conditionals": "^2.1.0", 71 | "postcss-for": "^2.1.1", 72 | "prettier": "^3.6.2", 73 | "tailwind-merge": "^3.3.1", 74 | "tailwindcss": "^4.1.17", 75 | "typescript": "~5.9.3", 76 | "vite": "^7.2.2", 77 | "vue-tsc": "^3.1.3" 78 | }, 79 | "packageManager": "pnpm@10.15.0" 80 | } 81 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help us improve 3 | title: '[Bug]: ' 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for reporting a bug! Before submitting, please search [existing issues](https://github.com/Zephyruso/zashboard/issues) to see if your problem has already been reported. 9 | 10 | - type: input 11 | id: zashboard-version 12 | attributes: 13 | label: zashboard Version 14 | description: The version of zashboard you are using (e.g., 1.81.0 or gh-pages commit hash) 15 | placeholder: 'e.g., 1.81.0' 16 | validations: 17 | required: true 18 | 19 | - type: input 20 | id: system-info 21 | attributes: 22 | label: System Information 23 | description: Your operating system and version 24 | placeholder: 'e.g., Windows 10, macOS Catalina 10.15.7, Ubuntu 20.04' 25 | validations: 26 | required: true 27 | 28 | - type: input 29 | id: zashboard-usage 30 | attributes: 31 | label: zashboard Usage 32 | description: How you are using zashboard (e.g., sing-box cli, mihomo cli, openwrt plugin like Nikki, Docker) 33 | placeholder: 'e.g., sing-box cli, mihomo cli, openwrt plugin like Nikki, Docker' 34 | validations: 35 | required: true 36 | 37 | - type: input 38 | id: browser-info 39 | attributes: 40 | label: Browser Information 41 | description: Your browser and version 42 | placeholder: 'e.g., Chrome 91.0, Firefox 89.0, Safari 14.1' 43 | validations: 44 | required: true 45 | 46 | - type: textarea 47 | id: steps-to-reproduce 48 | attributes: 49 | label: Steps to Reproduce 50 | description: Detailed steps to reproduce this bug 51 | placeholder: | 52 | 1. First step 53 | 2. Second step 54 | 3. Third step 55 | validations: 56 | required: true 57 | 58 | - type: textarea 59 | id: expected-behavior 60 | attributes: 61 | label: Expected Behavior 62 | description: What you expected to happen 63 | placeholder: 'Describe the behavior you expected to see' 64 | validations: 65 | required: true 66 | 67 | - type: textarea 68 | id: actual-behavior 69 | attributes: 70 | label: Actual Behavior 71 | description: What actually happened 72 | placeholder: 'Describe what actually happened' 73 | validations: 74 | required: true 75 | 76 | - type: textarea 77 | id: screenshot 78 | attributes: 79 | label: Bug Screenshot 80 | description: If available, please paste the screenshot here 81 | placeholder: 'Paste the screenshot here' 82 | validations: 83 | required: false 84 | 85 | - type: textarea 86 | id: additional-context 87 | attributes: 88 | label: Additional Context 89 | description: Any other context or information that might be helpful in resolving the issue 90 | placeholder: 'Other relevant information' 91 | validations: 92 | required: false 93 | -------------------------------------------------------------------------------- /src/components/proxies/ProxiesByProvider.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 101 | -------------------------------------------------------------------------------- /src/components/proxies/LatencyTag.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 104 | -------------------------------------------------------------------------------- /src/composables/renderProxies.ts: -------------------------------------------------------------------------------- 1 | import { NOT_CONNECTED, PROXY_SORT_TYPE } from '@/constant' 2 | import { isProxyGroup } from '@/helper' 3 | import { getLatencyByName, proxiesFilter } from '@/store/proxies' 4 | import { hideUnavailableProxies, proxySortType, useSmartGroupSort } from '@/store/settings' 5 | import { smartOrderMap } from '@/store/smart' 6 | import { computed, type ComputedRef } from 'vue' 7 | 8 | export function useRenderProxies(proxies: ComputedRef, proxyGroup?: string) { 9 | const renderProxies = computed(() => { 10 | return getRenderProxies(proxies.value, proxyGroup) 11 | }) 12 | const availableProxies = computed(() => { 13 | return renderProxies.value.filter( 14 | (proxy) => getLatencyByName(proxy, proxyGroup) !== NOT_CONNECTED, 15 | ).length 16 | }) 17 | 18 | const proxiesCount = computed(() => { 19 | const all = proxies.value.length 20 | 21 | return `${availableProxies.value}/${all}` 22 | }) 23 | 24 | return { 25 | renderProxies, 26 | proxiesCount, 27 | } 28 | } 29 | 30 | const getRenderProxies = (proxies: string[], groupName?: string) => { 31 | const latencyMap = new Map() 32 | 33 | proxies = [...proxies] 34 | proxies.forEach((name) => { 35 | latencyMap.set(name, getLatencyByName(name, groupName)) 36 | }) 37 | 38 | if (hideUnavailableProxies.value) { 39 | proxies = proxies.filter((name) => { 40 | return isProxyGroup(name) || latencyMap.get(name)! > NOT_CONNECTED 41 | }) 42 | } 43 | 44 | if (proxiesFilter.value) { 45 | const filters = proxiesFilter.value.split(' ').map((f) => f.toLowerCase().trim()) 46 | 47 | proxies = proxies.filter((name) => { 48 | name = name.toLowerCase() 49 | return filters.every((f) => name.includes(f)) 50 | }) 51 | } 52 | 53 | if (useSmartGroupSort.value && smartOrderMap.value[groupName!]) { 54 | const orderMap = smartOrderMap.value[groupName!] 55 | 56 | return proxies.sort((a, b) => { 57 | const ia = orderMap[a] ?? Number.MAX_SAFE_INTEGER 58 | const ib = orderMap[b] ?? Number.MAX_SAFE_INTEGER 59 | return ia - ib 60 | }) 61 | } 62 | 63 | if (proxySortType.value === PROXY_SORT_TYPE.DEFAULT) { 64 | return proxies 65 | } 66 | 67 | const proxyGroups: string[] = [] 68 | const proxyNodes: string[] = [] 69 | 70 | proxies.forEach((proxy) => { 71 | if (isProxyGroup(proxy)) { 72 | proxyGroups.push(proxy) 73 | } else { 74 | proxyNodes.push(proxy) 75 | } 76 | }) 77 | 78 | const getLatencyForSort = (name: string) => { 79 | const latency = latencyMap.get(name)! 80 | return latency === 0 ? Infinity : latency 81 | } 82 | const sortFuncMap = { 83 | [PROXY_SORT_TYPE.NAME_ASC]: (prev: string, next: string) => prev.localeCompare(next), 84 | [PROXY_SORT_TYPE.NAME_DESC]: (prev: string, next: string) => next.localeCompare(prev), 85 | [PROXY_SORT_TYPE.LATENCY_ASC]: (prev: string, next: string) => 86 | getLatencyForSort(prev) - getLatencyForSort(next), 87 | [PROXY_SORT_TYPE.LATENCY_DESC]: (prev: string, next: string) => 88 | getLatencyForSort(next) - getLatencyForSort(prev), 89 | } 90 | const sortFunc = sortFuncMap[proxySortType.value] 91 | 92 | return proxyGroups.concat(proxyNodes.sort(sortFunc)) 93 | } 94 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { ROUTE_NAME } from '@/constant' 2 | import { renderRoutes } from '@/helper' 3 | import { i18n } from '@/i18n' 4 | import { language } from '@/store/settings' 5 | import { activeBackend } from '@/store/setup' 6 | import ConnectionsPage from '@/views/ConnectionsPage.vue' 7 | import HomePage from '@/views/HomePage.vue' 8 | import LogsPage from '@/views/LogsPage.vue' 9 | import OverviewPage from '@/views/OverviewPage.vue' 10 | import ProxiesPage from '@/views/ProxiesPage.vue' 11 | import RulesPage from '@/views/RulesPage.vue' 12 | import SettingsPage from '@/views/SettingsPage.vue' 13 | import SetupPage from '@/views/SetupPage.vue' 14 | import { useTitle } from '@vueuse/core' 15 | import { watch } from 'vue' 16 | import { createRouter, createWebHashHistory } from 'vue-router' 17 | 18 | const childrenRouter = [ 19 | { 20 | path: 'proxies', 21 | name: ROUTE_NAME.proxies, 22 | component: ProxiesPage, 23 | }, 24 | { 25 | path: 'overview', 26 | name: ROUTE_NAME.overview, 27 | component: OverviewPage, 28 | }, 29 | { 30 | path: 'connections', 31 | name: ROUTE_NAME.connections, 32 | component: ConnectionsPage, 33 | }, 34 | { 35 | path: 'logs', 36 | name: ROUTE_NAME.logs, 37 | component: LogsPage, 38 | }, 39 | { 40 | path: 'rules', 41 | name: ROUTE_NAME.rules, 42 | component: RulesPage, 43 | }, 44 | { 45 | path: 'settings', 46 | name: ROUTE_NAME.settings, 47 | component: SettingsPage, 48 | }, 49 | ] 50 | 51 | const router = createRouter({ 52 | history: createWebHashHistory(import.meta.env.BASE_URL), 53 | routes: [ 54 | { 55 | path: '/', 56 | redirect: ROUTE_NAME.proxies, 57 | component: HomePage, 58 | children: childrenRouter, 59 | }, 60 | { 61 | path: '/setup', 62 | name: ROUTE_NAME.setup, 63 | component: SetupPage, 64 | }, 65 | { 66 | path: '/:catchAll(.*)', 67 | redirect: ROUTE_NAME.proxies, 68 | }, 69 | ], 70 | }) 71 | 72 | const title = useTitle('zashboard') 73 | const setTitleByName = (name: string | symbol | undefined) => { 74 | if (typeof name === 'string' && activeBackend.value) { 75 | title.value = `zashboard | ${i18n.global.t(name)}` 76 | } else { 77 | title.value = 'zashboard' 78 | } 79 | } 80 | 81 | router.beforeEach((to, from) => { 82 | const toIndex = renderRoutes.value.findIndex((item) => item === to.name) 83 | const fromIndex = renderRoutes.value.findIndex((item) => item === from.name) 84 | 85 | if (toIndex === 0 && fromIndex === renderRoutes.value.length - 1) { 86 | to.meta.transition = 'slide-left' 87 | } else if (toIndex === renderRoutes.value.length - 1 && fromIndex === 0) { 88 | to.meta.transition = 'slide-right' 89 | } else if (toIndex !== fromIndex) { 90 | to.meta.transition = toIndex < fromIndex ? 'slide-right' : 'slide-left' 91 | } 92 | 93 | if (!activeBackend.value && to.name !== ROUTE_NAME.setup) { 94 | router.push({ name: ROUTE_NAME.setup }) 95 | } 96 | }) 97 | 98 | router.afterEach((to) => { 99 | setTitleByName(to.name) 100 | }) 101 | 102 | watch(language, () => { 103 | setTimeout(() => { 104 | setTitleByName(router.currentRoute.value.name) 105 | }) 106 | }) 107 | 108 | export default router 109 | -------------------------------------------------------------------------------- /src/components/settings/ConnectionCardSettings.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 103 | -------------------------------------------------------------------------------- /src/components/settings/IconSettings.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 112 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | export type Backend = { 2 | host: string 3 | port: string 4 | secondaryPath: string 5 | password: string 6 | protocol: string 7 | uuid: string 8 | label?: string 9 | disableUpgradeCore?: boolean 10 | } 11 | 12 | export type Config = { 13 | port: number 14 | 'socks-port': number 15 | 'redir-port': number 16 | 'tproxy-port': number 17 | 'mixed-port': number 18 | 'allow-lan': boolean 19 | 'bind-address': string 20 | mode: string 21 | 'mode-list': string[] 22 | modes: string[] 23 | 'log-level': string 24 | ipv6: boolean 25 | tun: { 26 | enable: boolean 27 | } 28 | } 29 | 30 | export type History = { 31 | time: string 32 | delay: number 33 | }[] 34 | 35 | export type Proxy = { 36 | name: string 37 | type: string 38 | history: History 39 | extra: Record< 40 | string, 41 | { 42 | alive: boolean 43 | history: History 44 | } 45 | > 46 | all?: string[] 47 | udp: boolean 48 | xudp?: boolean 49 | now: string 50 | fixed?: string 51 | icon: string 52 | hidden?: boolean 53 | testUrl?: string 54 | 'dialer-proxy'?: string 55 | 'provider-name'?: string 56 | } 57 | 58 | export type SubscriptionInfo = { 59 | Download?: number 60 | Upload?: number 61 | Total?: number 62 | Expire?: number 63 | } 64 | 65 | export type ProxyProvider = { 66 | subscriptionInfo?: SubscriptionInfo 67 | name: string 68 | proxies: Proxy[] 69 | testUrl: string 70 | updatedAt: string 71 | vehicleType: string 72 | } 73 | 74 | export type Rule = { 75 | type: string 76 | payload: string 77 | proxy: string 78 | size: number 79 | disabled: boolean 80 | uuid: string 81 | } 82 | 83 | export type RuleProvider = { 84 | behavior: string 85 | format: string 86 | name: string 87 | ruleCount: number 88 | type: string 89 | updatedAt: string 90 | vehicleType: string 91 | } 92 | 93 | export type ConnectionRawMessage = { 94 | id: string 95 | download: number 96 | upload: number 97 | chains: string[] 98 | rule: string 99 | rulePayload: string 100 | start: string 101 | metadata: { 102 | destinationGeoIP: string 103 | destinationIP: string 104 | destinationIPASN: string 105 | destinationPort: string 106 | dnsMode: string 107 | dscp: number 108 | host: string 109 | inboundIP: string 110 | inboundName: string 111 | inboundPort: string 112 | inboundUser: string 113 | network: string 114 | process: string 115 | processPath: string 116 | remoteDestination: string 117 | sniffHost: string 118 | sourceGeoIP: string 119 | sourceIP: string 120 | sourceIPASN: string 121 | sourcePort: string 122 | specialProxy: string 123 | specialRules: string 124 | type: string 125 | uid: number 126 | smartBlock: string 127 | } 128 | } 129 | 130 | export type Connection = ConnectionRawMessage & { 131 | downloadSpeed: number 132 | uploadSpeed: number 133 | } 134 | 135 | export type Log = { 136 | type: 'info' | 'warning' | 'error' | 'debug' 137 | payload: string 138 | } 139 | 140 | export type LogWithSeq = Log & { seq: number; time: string } 141 | 142 | export type DNSQuery = { 143 | AD: boolean 144 | CD: boolean 145 | RA: boolean 146 | RD: boolean 147 | TC: boolean 148 | status: number 149 | Question: { 150 | Name: string 151 | Qtype: number 152 | Qclass: number 153 | }[] 154 | Answer?: { 155 | TTL: number 156 | data: string 157 | name: string 158 | type: number 159 | }[] 160 | } 161 | 162 | export type SourceIPLabel = { 163 | key: string 164 | label: string 165 | id: string 166 | scope?: string[] 167 | } 168 | 169 | // smart core 170 | export interface NodeRank { 171 | Name: string 172 | Rank: string 173 | Weight: number 174 | } 175 | -------------------------------------------------------------------------------- /src/assets/sing-box.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/components/common/TextInput.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 121 | -------------------------------------------------------------------------------- /src/helper/utils.ts: -------------------------------------------------------------------------------- 1 | import { MIN_PROXY_CARD_WIDTH, PROXY_CARD_SIZE } from '@/constant' 2 | import type { Backend } from '@/types' 3 | import { useMediaQuery } from '@vueuse/core' 4 | import dayjs from 'dayjs' 5 | import prettyBytes, { type Options } from 'pretty-bytes' 6 | 7 | export const isPreferredDark = useMediaQuery('(prefers-color-scheme: dark)') 8 | export const isMiddleScreen = useMediaQuery('(max-width: 768px)') 9 | export const isPWA = (() => { 10 | return window.matchMedia('(display-mode: standalone)').matches || navigator.standalone 11 | })() 12 | 13 | export const prettyBytesHelper = (bytes: number, opts?: Options) => { 14 | return prettyBytes(bytes, { 15 | binary: false, 16 | ...opts, 17 | }) 18 | } 19 | 20 | export const fromNow = (timestamp: string) => { 21 | return dayjs(timestamp).fromNow() 22 | } 23 | 24 | export const exportSettings = () => { 25 | const settings: Record = {} 26 | 27 | for (const key in localStorage) { 28 | if (key.startsWith('config/') || key.startsWith('setup/')) { 29 | settings[key] = localStorage.getItem(key) 30 | } 31 | } 32 | 33 | const blob = new Blob([JSON.stringify(settings, null, 2)], { type: 'application/json' }) 34 | const url = URL.createObjectURL(blob) 35 | const a = document.createElement('a') 36 | a.href = url 37 | a.download = 'zashboard-settings' 38 | a.click() 39 | URL.revokeObjectURL(url) 40 | } 41 | 42 | export const getUrlFromBackend = (end: Omit) => { 43 | return `${end.protocol}://${end.host}:${end.port}${end.secondaryPath || ''}` 44 | } 45 | 46 | export const getLabelFromBackend = (end: Omit) => { 47 | return end.label || getUrlFromBackend(end) 48 | } 49 | 50 | export const getMinCardWidth = (size: PROXY_CARD_SIZE) => { 51 | return size === PROXY_CARD_SIZE.LARGE ? MIN_PROXY_CARD_WIDTH.LARGE : MIN_PROXY_CARD_WIDTH.SMALL 52 | } 53 | 54 | export const SCROLLABLE_PARENT_CLASS = 'scrollable-parent' 55 | 56 | export const scrollIntoCenter = (el: HTMLElement) => { 57 | const scrollableParent = findScrollableParent(el) 58 | 59 | if (!scrollableParent) return 60 | 61 | const elRect = el.getBoundingClientRect() 62 | const parentRect = scrollableParent.getBoundingClientRect() 63 | 64 | if (elRect.top >= parentRect.top && elRect.bottom <= parentRect.bottom) return 65 | 66 | const parentTop = scrollableParent.offsetTop 67 | const childTop = el.offsetTop 68 | 69 | const centerOffset = 70 | childTop - parentTop - scrollableParent.clientHeight / 2 + el.clientHeight / 2 71 | 72 | scrollableParent.scrollTo({ 73 | top: centerOffset, 74 | behavior: 'smooth', 75 | }) 76 | } 77 | 78 | export const findScrollableParent = (el: HTMLElement | null): HTMLElement | null => { 79 | const parent = el?.parentElement 80 | 81 | if ( 82 | parent?.classList.contains(SCROLLABLE_PARENT_CLASS) && 83 | parent.scrollHeight > parent.clientHeight 84 | ) { 85 | return parent 86 | } 87 | 88 | return parent ? findScrollableParent(parent) : null 89 | } 90 | 91 | export const getBackendFromUrl = () => { 92 | const query = new URLSearchParams( 93 | window.location.search || location.hash.match(/\?.*$/)?.[0]?.replace('?', ''), 94 | ) 95 | 96 | if (query.has('hostname')) { 97 | return { 98 | protocol: query.get('http') 99 | ? 'http' 100 | : query.get('https') 101 | ? 'https' 102 | : window.location.protocol.replace(':', ''), 103 | secondaryPath: query.get('secondaryPath') || '', 104 | host: query.get('hostname') as string, 105 | port: query.get('port') as string, 106 | password: query.get('secret') || '', 107 | label: query.get('label') || '', 108 | disableUpgradeCore: 109 | query.get('disableUpgradeCore') === '1' || query.get('disableUpgradeCore') === 'core', 110 | } 111 | } 112 | return null 113 | } 114 | -------------------------------------------------------------------------------- /src/components/overview/IPCheck.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 124 | -------------------------------------------------------------------------------- /src/views/ProxiesPage.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 125 | -------------------------------------------------------------------------------- /src/components/common/ImportSettings.vue: -------------------------------------------------------------------------------- 1 | 82 | 83 | 132 | -------------------------------------------------------------------------------- /src/components/settings/OverviewSettings.vue: -------------------------------------------------------------------------------- 1 | 94 | 95 | 121 | -------------------------------------------------------------------------------- /src/composables/swipe.ts: -------------------------------------------------------------------------------- 1 | import { CONNECTION_TAB_TYPE, PROXY_TAB_TYPE, ROUTE_NAME, RULE_TAB_TYPE } from '@/constant' 2 | import { renderRoutes } from '@/helper' 3 | import { connectionTabShow } from '@/store/connections' 4 | import { proxiesTabShow, proxyProviederList } from '@/store/proxies' 5 | import { ruleProviderList, rulesTabShow } from '@/store/rules' 6 | import { swipeInPages, swipeInTabs } from '@/store/settings' 7 | import { useSwipe } from '@vueuse/core' 8 | import { flatten } from 'lodash' 9 | import { computed, ref, watch } from 'vue' 10 | import { useRoute, useRouter } from 'vue-router' 11 | 12 | export const disableSwipe = ref(false) 13 | 14 | export const useSwipeRouter = () => { 15 | const swiperRef = ref() 16 | const route = useRoute() 17 | const router = useRouter() 18 | const { direction } = useSwipe(swiperRef, { threshold: 75 }) 19 | 20 | const swipeList = computed(() => { 21 | return flatten( 22 | renderRoutes.value.map((r) => { 23 | if (swipeInTabs.value) { 24 | if (r === ROUTE_NAME.proxies && proxyProviederList.value.length > 0) { 25 | return Object.values(PROXY_TAB_TYPE).map((tab) => { 26 | return [ 27 | () => route.name === ROUTE_NAME.proxies && proxiesTabShow.value === tab, 28 | () => { 29 | router.push({ name: ROUTE_NAME.proxies }) 30 | proxiesTabShow.value = tab 31 | }, 32 | ] 33 | }) 34 | } else if (r === ROUTE_NAME.connections) { 35 | return Object.values(CONNECTION_TAB_TYPE).map((tab) => { 36 | return [ 37 | () => route.name === ROUTE_NAME.connections && connectionTabShow.value === tab, 38 | () => { 39 | router.push({ name: ROUTE_NAME.connections }) 40 | connectionTabShow.value = tab 41 | }, 42 | ] 43 | }) 44 | } else if (r === ROUTE_NAME.rules && ruleProviderList.value.length > 0) { 45 | return Object.values(RULE_TAB_TYPE).map((tab) => { 46 | return [ 47 | () => route.name === ROUTE_NAME.rules && rulesTabShow.value === tab, 48 | () => { 49 | router.push({ name: ROUTE_NAME.rules }) 50 | rulesTabShow.value = tab 51 | }, 52 | ] 53 | }) 54 | } 55 | } 56 | 57 | return [[() => route.name === r, () => router.push({ name: r })]] 58 | }), 59 | ) 60 | }) 61 | 62 | const getNextIndexInSwipeList = () => { 63 | return swipeList.value.findIndex((s) => s[0]()) 64 | } 65 | 66 | const getNextRouteName = () => { 67 | const routeName = route.name as ROUTE_NAME 68 | 69 | if (routeName === ROUTE_NAME.setup) { 70 | return router.push({ name: ROUTE_NAME.proxies }) 71 | } 72 | 73 | return swipeList.value[(getNextIndexInSwipeList() + 1) % swipeList.value.length]?.[1]?.() 74 | } 75 | const getPrevRouteName = () => { 76 | const routeName = route.name as ROUTE_NAME 77 | 78 | if (routeName === ROUTE_NAME.setup) { 79 | return router.push({ name: ROUTE_NAME.proxies }) 80 | } 81 | 82 | return swipeList.value[ 83 | (getNextIndexInSwipeList() - 1 + swipeList.value.length) % swipeList.value.length 84 | ]?.[1]?.() 85 | } 86 | 87 | const isInputActive = () => { 88 | const activeEl = document.activeElement 89 | return activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA') 90 | } 91 | 92 | watch(direction, () => { 93 | if (!swipeInPages.value) return 94 | 95 | if ( 96 | document.querySelector('dialog:modal') || 97 | isInputActive() || 98 | window.getSelection()?.toString()?.length || 99 | disableSwipe.value 100 | ) 101 | return 102 | if (direction.value === 'right') { 103 | getPrevRouteName() 104 | } else if (direction.value === 'left') { 105 | getNextRouteName() 106 | } 107 | }) 108 | 109 | return { 110 | swiperRef, 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/components/settings/SourceIPLabels.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 134 | -------------------------------------------------------------------------------- /src/components/settings/GroupTestUrlsSettings.vue: -------------------------------------------------------------------------------- 1 | 84 | 85 | 128 | -------------------------------------------------------------------------------- /src/components/settings/ConnectionsSettings.vue: -------------------------------------------------------------------------------- 1 | 105 | 106 | 131 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 128 | 129 | 149 | -------------------------------------------------------------------------------- /src/components/proxies/ProxyNodeCard.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 129 | 130 | 135 | -------------------------------------------------------------------------------- /src/components/settings/SourceIPInput.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 141 | -------------------------------------------------------------------------------- /src/components/proxies/ProxyGroup.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 136 | -------------------------------------------------------------------------------- /src/components/connections/ConnectionDetails.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 133 | -------------------------------------------------------------------------------- /src/components/proxies/ProxyPreview.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 141 | 142 | 148 | --------------------------------------------------------------------------------