Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Prism</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2502
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.18.0",
|
||||
"echarts": "^6.1.0",
|
||||
"element-plus": "^2.14.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.34",
|
||||
"vue-i18n": "^10.0.7",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.12.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"vue-tsc": "^3.2.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" role="img" aria-label="Prism">
|
||||
<rect width="32" height="32" rx="8" fill="#0f7a62"/>
|
||||
<path
|
||||
d="M16 6.5L24.5 24.5H7.5L16 6.5Z"
|
||||
stroke="#fafafa"
|
||||
stroke-width="1.8"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.5 16h23"
|
||||
stroke="#fafafa"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M10.5 12l11 8"
|
||||
stroke="#a7f3d0"
|
||||
stroke-width="1.25"
|
||||
stroke-linecap="round"
|
||||
opacity="0.9"
|
||||
/>
|
||||
<path
|
||||
d="M21.5 12l-11 8"
|
||||
stroke="#6ee7b7"
|
||||
stroke-width="1.25"
|
||||
stroke-linecap="round"
|
||||
opacity="0.75"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 643 B |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ElConfigProvider } from 'element-plus'
|
||||
import { useLocaleStore } from '@/stores/locale'
|
||||
|
||||
const localeStore = useLocaleStore()
|
||||
const elementLocale = computed(() => localeStore.elementLocale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-config-provider :locale="elementLocale">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios'
|
||||
import router from '@/router'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('prism_token')
|
||||
if (token && token !== 'disabled') {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401 && err.config?.url !== '/auth/login') {
|
||||
localStorage.removeItem('prism_token')
|
||||
if (router.currentRoute.value.name !== 'login') {
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
export default api
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 3.5L19.5 19.5H4.5L12 3.5Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h18"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.5 9l7 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
opacity="0.75"
|
||||
/>
|
||||
<path
|
||||
d="M15.5 9l-7 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
opacity="0.55"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title?: string
|
||||
padded?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-card">
|
||||
<div v-if="title || $slots.header" class="app-card__header">
|
||||
<slot name="header">
|
||||
<h3 class="app-card__title">{{ title }}</h3>
|
||||
</slot>
|
||||
</div>
|
||||
<div :class="padded ? 'app-card__body--padded' : ''">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<p v-if="description" class="page-desc">{{ description }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="page-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number | string
|
||||
}>(),
|
||||
{ size: 24 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M12 3.5L19.5 19.5H4.5L12 3.5Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 12h18"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.5 9l7 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
opacity="0.75"
|
||||
/>
|
||||
<path
|
||||
d="M15.5 9l-7 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
opacity="0.55"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
value: string | number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stat-card">
|
||||
<div class="stat-card__label">{{ label }}</div>
|
||||
<div class="stat-card__value">{{ value }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import zhTw from 'element-plus/es/locale/lang/zh-tw'
|
||||
import en from 'element-plus/es/locale/lang/en'
|
||||
|
||||
import zhCN from './locales/zh-CN'
|
||||
import zhTW from './locales/zh-TW'
|
||||
import enUS from './locales/en'
|
||||
import { isLocaleCode, type LocaleCode } from './types'
|
||||
|
||||
export const LOCALE_STORAGE_KEY = 'prism_locale'
|
||||
export const DEFAULT_LOCALE: LocaleCode = 'zh-CN'
|
||||
|
||||
export const elementLocales = {
|
||||
'zh-CN': zhCn,
|
||||
'zh-TW': zhTw,
|
||||
en,
|
||||
} as const
|
||||
|
||||
function readStoredLocale(): LocaleCode {
|
||||
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
|
||||
return stored && isLocaleCode(stored) ? stored : DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: readStoredLocale(),
|
||||
fallbackLocale: DEFAULT_LOCALE,
|
||||
messages: {
|
||||
'zh-CN': zhCN,
|
||||
'zh-TW': zhTW,
|
||||
en: enUS,
|
||||
},
|
||||
})
|
||||
|
||||
export function applyDocumentLocale(locale: LocaleCode) {
|
||||
document.documentElement.lang = locale
|
||||
}
|
||||
|
||||
export function setI18nLocale(locale: LocaleCode) {
|
||||
i18n.global.locale.value = locale
|
||||
applyDocumentLocale(locale)
|
||||
}
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,344 @@
|
||||
export default {
|
||||
common: {
|
||||
prism: 'Prism',
|
||||
refresh: 'Refresh',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
search: 'Search',
|
||||
delete: 'Delete',
|
||||
import: 'Import',
|
||||
export: 'Export',
|
||||
add: 'Add',
|
||||
create: 'Create',
|
||||
revoke: 'Revoke',
|
||||
copy: 'Copy',
|
||||
loading: 'Loading',
|
||||
unknown: 'Unknown',
|
||||
none: '—',
|
||||
success: 'Success',
|
||||
failed: 'Failed',
|
||||
all: 'All',
|
||||
manual: 'Manual',
|
||||
subscription: 'Subscription',
|
||||
source: 'Source',
|
||||
name: 'Name',
|
||||
type: 'Type',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
upload: 'Upload',
|
||||
download: 'Download',
|
||||
connections: 'Connections',
|
||||
time: 'Time',
|
||||
level: 'Level',
|
||||
message: 'Message',
|
||||
testing: 'Testing',
|
||||
saved: 'Saved',
|
||||
empty: 'No data',
|
||||
management: 'Management',
|
||||
logout: 'Sign out',
|
||||
minutes: '{n} min',
|
||||
seconds: '{n} sec',
|
||||
periodHours: 'Last {n} hours',
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
subscriptions: 'Subscriptions',
|
||||
proxies: 'Proxies',
|
||||
outbound: 'Outbound',
|
||||
rules: 'Rules',
|
||||
trafficLogs: 'Request Logs',
|
||||
logs: 'Runtime Logs',
|
||||
settings: 'Settings',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'HTTP / SOCKS5 proxy gateway console',
|
||||
title: 'Sign in',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'default: admin',
|
||||
submit: 'Sign in',
|
||||
hint: 'Default password is admin on first use. Change it in Settings.',
|
||||
failed: 'Sign in failed',
|
||||
wrongPassword: 'Incorrect password',
|
||||
wrongPasswordRemaining: 'Incorrect password. {n} attempts left',
|
||||
tooManyAttempts: 'Too many attempts. Try again in {wait}',
|
||||
},
|
||||
dashboard: {
|
||||
title: 'Dashboard',
|
||||
desc: 'Live traffic, domain/node breakdown, and connection overview',
|
||||
checking: 'Checking',
|
||||
engineRunning: 'Engine running',
|
||||
engineStopped: 'Engine stopped',
|
||||
totalNodes: 'Total nodes',
|
||||
aliveNodes: 'Alive nodes',
|
||||
activeConnections: 'Active connections',
|
||||
downloadRate: 'Download rate',
|
||||
domainTraffic: 'Domain traffic',
|
||||
nodeTraffic: 'Node traffic',
|
||||
trafficTrend: 'Traffic trend',
|
||||
tabLive: 'Active connections',
|
||||
domain: 'Domain',
|
||||
outboundNode: 'Outbound node',
|
||||
hintLiveDomain: 'Active connection traffic grouped by domain and outbound node',
|
||||
hintPeriodDomain: 'Aggregated from request logs by domain and outbound node',
|
||||
hintLiveNode: 'Active traffic by leaf proxy node (policy groups skipped)',
|
||||
hintPeriodNode: 'Completed request traffic per leaf node in the last 24 hours',
|
||||
seriesDownload: 'Download',
|
||||
seriesUpload: 'Upload',
|
||||
},
|
||||
subscriptions: {
|
||||
title: 'Subscriptions',
|
||||
desc: 'Add and schedule Clash or link-based subscriptions',
|
||||
add: 'Add subscription',
|
||||
url: 'URL',
|
||||
nodeCount: 'Nodes',
|
||||
lastFetch: 'Last updated',
|
||||
normal: 'OK',
|
||||
error: 'Error',
|
||||
refresh: 'Refresh',
|
||||
dialogTitle: 'Add subscription',
|
||||
interval: 'Update interval',
|
||||
intervalHint: 'Seconds, minimum 300',
|
||||
typeAuto: 'Auto detect',
|
||||
typeClash: 'Clash YAML',
|
||||
typeLinks: 'Node links',
|
||||
typeHint: 'Use Auto detect for short links and Base64 node lists',
|
||||
added: 'Subscription added',
|
||||
refreshed: 'Refreshed',
|
||||
deleted: 'Deleted',
|
||||
confirmDelete: 'Delete subscription "{name}"?',
|
||||
},
|
||||
proxies: {
|
||||
title: 'Proxies',
|
||||
desc: 'Node status, latency, country tags, and filters',
|
||||
rebuildCountries: 'Rebuild countries',
|
||||
addNode: 'Add node',
|
||||
testAll: 'Test all',
|
||||
testingProgress: 'Testing {progress}',
|
||||
cancelTest: 'Cancel test',
|
||||
testOne: 'Test',
|
||||
country: 'Country',
|
||||
latency: 'Latency',
|
||||
notTested: 'N/A',
|
||||
alive: 'Alive',
|
||||
filterCountry: 'Filter by country',
|
||||
allCountries: 'All countries',
|
||||
testingHint: 'Testing nodes one by one. Do not close this page.',
|
||||
tabAll: 'All',
|
||||
countriesRebuilt: 'Updated country tags for {n} nodes',
|
||||
noTestable: 'No nodes to test',
|
||||
testCancelled: 'Test cancelled',
|
||||
testDone: 'Test completed',
|
||||
testDoneFailed: 'Test completed with {n} failures',
|
||||
nodeAdded: 'Node added',
|
||||
exported: 'Nodes exported',
|
||||
exportJson: 'JSON',
|
||||
exportLinks: 'Share links (one per line)',
|
||||
importTitle: 'Import manual nodes',
|
||||
importMode: 'Import mode',
|
||||
append: 'Append to existing nodes',
|
||||
replace: 'Replace all manual nodes',
|
||||
importFile: 'Node file',
|
||||
importContent: 'Node content',
|
||||
importFileHint: 'JSON or share links (ss/socks5/http/trojan)',
|
||||
importPlaceholder: 'One link per line, or paste JSON; lines starting with # are comments',
|
||||
importConfirmReplace: 'This will delete all manual nodes and replace them. Continue?',
|
||||
importConfirmTitle: 'Confirm import',
|
||||
pasteRequired: 'Paste nodes or choose a file',
|
||||
imported: 'Imported {n} nodes',
|
||||
importedPartial: 'Imported {n}, skipped {s}',
|
||||
importFailed: 'Import failed',
|
||||
addDialogTitle: 'Add manual node',
|
||||
server: 'Server',
|
||||
port: 'Port',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
chooseFile: 'Choose file',
|
||||
},
|
||||
outbound: {
|
||||
title: 'Outbound',
|
||||
desc: 'Manage policy groups and default outbound; route rules are on the Rules page',
|
||||
defaultTitle: 'Default outbound',
|
||||
defaultHint: 'Policy group or DIRECT when no rule matches',
|
||||
save: 'Save',
|
||||
countryPools: 'Country pools (url-test)',
|
||||
countryPoolsHint: 'Per-country url-test groups that pick the fastest node',
|
||||
policyGroups: 'Policy groups',
|
||||
countryPool: 'Country pool',
|
||||
currentNode: 'Current node',
|
||||
memberCount: 'Members',
|
||||
policyGroup: 'Policy group',
|
||||
switchNode: 'Switch node',
|
||||
auto: '(auto)',
|
||||
fallbackHint: 'Ordered fallback without latency test',
|
||||
skipDirect: 'Auto latency test (excludes DIRECT)',
|
||||
emptyCountry: 'No country pools (tag nodes and reload)',
|
||||
emptyPolicy: 'No policy groups',
|
||||
saved: 'Default outbound saved',
|
||||
groupSwitched: 'Policy group switched',
|
||||
policyGroupsHint: 'Only Selector groups allow manual node switch; URLTest / Fallback are chosen by the core',
|
||||
typeSelector: 'Manual select',
|
||||
typeURLTest: 'URL test',
|
||||
typeFallback: 'Fallback',
|
||||
typeLoadBalance: 'Load balance',
|
||||
typeRelay: 'Relay',
|
||||
countryPoolName: '{name} (country pool)',
|
||||
policyGroupName: '{name} ({type})',
|
||||
},
|
||||
rules: {
|
||||
title: 'Routing rules',
|
||||
desc: 'Manual rules override subscription rules; route domains to country pools',
|
||||
testCard: 'Rule match test',
|
||||
testPlaceholder: 'Enter URL or domain, e.g. https://www.google.com',
|
||||
test: 'Test',
|
||||
testHint: 'Simulates merged rule priority; GEOIP/GEOSITE needs local Geo data',
|
||||
parsedHost: 'Host',
|
||||
matchedRule: 'Matched rule',
|
||||
outboundTarget: 'Outbound',
|
||||
manualRules: 'Manual rules (high priority)',
|
||||
mergedPreview: 'Merged rules preview',
|
||||
mergedSummary: '{total} rules, fallback {policy}',
|
||||
priority: 'Priority',
|
||||
match: 'Match',
|
||||
target: 'Target',
|
||||
rule: 'Rule',
|
||||
seq: '#',
|
||||
ruleAdded: 'Rule added',
|
||||
ruleDeleted: 'Deleted',
|
||||
confirmDelete: 'Delete this rule?',
|
||||
urlRequired: 'Enter a URL or domain',
|
||||
exported: 'Rules exported',
|
||||
exportJson: 'JSON',
|
||||
exportText: 'Mihomo text (one per line)',
|
||||
importTitle: 'Import manual rules',
|
||||
importConfirmReplace: 'This will delete all manual rules and replace them. Continue?',
|
||||
pasteRequired: 'Paste rules or choose a file',
|
||||
imported: 'Imported {n} rules',
|
||||
importedPartial: 'Imported {n}, skipped {s}',
|
||||
importFailed: 'Import failed',
|
||||
addDialogTitle: 'Add rule',
|
||||
matchValue: 'Match value',
|
||||
matchPlaceholder: 'e.g. google.com',
|
||||
outboundType: 'Outbound type',
|
||||
byCountry: 'By country',
|
||||
byPolicy: 'Policy / direct',
|
||||
outboundTargetLabel: 'Outbound target',
|
||||
selectCountryPool: 'Select country pool',
|
||||
selectPolicy: 'Select policy or DIRECT',
|
||||
countryPoolHint: 'Country pools use url-test to pick the fastest node',
|
||||
importFile: 'Rule file',
|
||||
importContent: 'Rule content',
|
||||
importFileHint: 'JSON or Mihomo rule text (DOMAIN-SUFFIX,example.com,PROXY)',
|
||||
importPlaceholder: 'One rule per line or JSON; # for comments',
|
||||
chooseFile: 'Choose file',
|
||||
importMode: 'Import mode',
|
||||
append: 'Append to existing rules',
|
||||
replace: 'Replace all manual rules',
|
||||
targetDirect: 'DIRECT',
|
||||
targetReject: 'REJECT',
|
||||
targetProxy: 'PROXY (default group)',
|
||||
targetGlobal: 'GLOBAL (subscription group)',
|
||||
countryFastest: '{name} (fastest)',
|
||||
},
|
||||
trafficLogs: {
|
||||
title: 'Request logs',
|
||||
desc: 'Matched rules, outbound nodes, and results after connections close',
|
||||
searchPlaceholder: 'Search domain, rule, or node',
|
||||
target: 'Target',
|
||||
matchedRule: 'Matched rule',
|
||||
outboundNode: 'Outbound node',
|
||||
chain: 'Chain',
|
||||
traffic: 'Traffic',
|
||||
result: 'Result',
|
||||
pageSuccess: '{ok}/{total} succeeded on this page',
|
||||
},
|
||||
logs: {
|
||||
title: 'Runtime logs',
|
||||
desc: 'Prism and proxy engine logs',
|
||||
runtime: 'Runtime',
|
||||
audit: 'Audit',
|
||||
searchPlaceholder: 'Search keyword',
|
||||
action: 'Action',
|
||||
resource: 'Resource',
|
||||
detail: 'Detail',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
desc: 'Proxy ports, scheduler, and security',
|
||||
reloadEngine: 'Reload engine',
|
||||
saved: 'Settings saved',
|
||||
engineReloaded: 'Engine reloaded',
|
||||
inboundApi: 'Inbound & API',
|
||||
proxyInbound: 'Proxy inbound',
|
||||
httpPort: 'HTTP port',
|
||||
socksPort: 'SOCKS5 port',
|
||||
proxyUser: 'Proxy username',
|
||||
proxyUserPlaceholder: 'Leave empty to disable auth',
|
||||
proxyPass: 'Proxy password',
|
||||
proxyPassPlaceholder: 'Leave empty to keep unchanged; clear username to disable auth',
|
||||
proxyAuthHint: 'HTTP/SOCKS5 will require these credentials; 127.0.0.1 is exempt',
|
||||
adminApi: 'Admin API',
|
||||
apiPort: 'API port',
|
||||
apiPortHint: 'Restart required after changing',
|
||||
mihomoApi: 'Mihomo API',
|
||||
apiSecret: 'API Secret',
|
||||
apiSecretPlaceholder: 'Clash API secret',
|
||||
scheduler: 'Scheduler',
|
||||
healthInterval: 'Health check interval',
|
||||
seconds: 'seconds',
|
||||
logLevel: 'Log level',
|
||||
security: 'Security',
|
||||
authEnabled: 'Enable login',
|
||||
loginMaxAttempts: 'Max login attempts',
|
||||
loginMaxAttemptsHint: 'Lock IP after this many failures within the lock window',
|
||||
loginLockMinutes: 'Login lock duration',
|
||||
loginLockMinutesHint: 'Minutes; blocked IP cannot retry during lock',
|
||||
newPassword: 'New password',
|
||||
newPasswordPlaceholder: 'Leave empty to keep unchanged',
|
||||
appearance: 'Appearance',
|
||||
uiLocale: 'Language',
|
||||
localeZhCN: '简体中文',
|
||||
localeZhTW: '繁體中文',
|
||||
localeEn: 'English',
|
||||
apiKeys: 'API Keys',
|
||||
apiKeysHint: 'Use keys to call the admin API without signing in. Header:',
|
||||
applyKey: 'Create API Key',
|
||||
},
|
||||
apiKey: {
|
||||
empty: 'No API keys',
|
||||
prefix: 'Key prefix',
|
||||
lastUsed: 'Last used',
|
||||
createdAt: 'Created',
|
||||
neverUsed: 'Never used',
|
||||
created: 'API Key created. Copy it now.',
|
||||
copied: 'Copied to clipboard',
|
||||
confirmRevoke: 'Revoke key "{name}" ({prefix})?',
|
||||
revoked: 'Revoked',
|
||||
dialogCreate: 'Create API Key',
|
||||
dialogSave: 'Save your API Key',
|
||||
name: 'Name',
|
||||
namePlaceholder: 'e.g. automation, ci',
|
||||
onceHint: 'Shown only once. You cannot view the full key again.',
|
||||
},
|
||||
geo: {
|
||||
title: 'Geo data',
|
||||
autoDownload: 'Auto download on start',
|
||||
autoDownloadHint: 'Download missing Geo files on startup using URLs below',
|
||||
countryMmdb: 'Country.mmdb',
|
||||
countryMmdbHint: 'GEOIP country database; clear and save to restore default URL',
|
||||
geoip: 'GEOIP',
|
||||
geoipHint: 'geoip.dat URL; clear and save to restore default',
|
||||
geosite: 'GEOSITE',
|
||||
geositeHint: 'geosite.dat URL; clear and save to restore default',
|
||||
localStatus: 'Local status',
|
||||
ready: 'Ready',
|
||||
notReady: 'Not ready',
|
||||
missing: 'Missing',
|
||||
downloadMissing: 'Download missing',
|
||||
forceUpdate: 'Force update all',
|
||||
updateHint: 'Save URL changes first, then update; GEOIP/GEOSITE rules need all three files',
|
||||
updated: 'Updated: {files}',
|
||||
upToDate: 'Geo data is up to date',
|
||||
updateFailed: 'Update failed',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
export default {
|
||||
common: {
|
||||
prism: 'Prism',
|
||||
refresh: '刷新',
|
||||
save: '保存',
|
||||
cancel: '取消',
|
||||
confirm: '确认',
|
||||
search: '搜索',
|
||||
delete: '删除',
|
||||
import: '导入',
|
||||
export: '导出',
|
||||
add: '添加',
|
||||
create: '创建',
|
||||
revoke: '吊销',
|
||||
copy: '复制',
|
||||
loading: '加载中',
|
||||
unknown: '未知',
|
||||
none: '—',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
all: '全部',
|
||||
manual: '手动',
|
||||
subscription: '订阅',
|
||||
source: '来源',
|
||||
name: '名称',
|
||||
type: '类型',
|
||||
status: '状态',
|
||||
actions: '操作',
|
||||
upload: '上行',
|
||||
download: '下行',
|
||||
connections: '连接数',
|
||||
time: '时间',
|
||||
level: '级别',
|
||||
message: '消息',
|
||||
testing: '测试中',
|
||||
saved: '已保存',
|
||||
empty: '暂无数据',
|
||||
management: '管理',
|
||||
logout: '退出登录',
|
||||
minutes: '{n} 分钟',
|
||||
seconds: '{n} 秒',
|
||||
periodHours: '近 {n} 小时累计',
|
||||
},
|
||||
nav: {
|
||||
dashboard: '监控大盘',
|
||||
subscriptions: '订阅管理',
|
||||
proxies: '代理节点',
|
||||
outbound: '出站管理',
|
||||
rules: '路由规则',
|
||||
trafficLogs: '请求日志',
|
||||
logs: '运行日志',
|
||||
settings: '系统设置',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'HTTP / SOCKS5 代理网关管理台',
|
||||
title: '登录',
|
||||
password: '密码',
|
||||
passwordPlaceholder: '默认 admin',
|
||||
submit: '登录',
|
||||
hint: '首次使用默认密码为 admin,请在设置中修改',
|
||||
failed: '登录失败',
|
||||
wrongPassword: '密码错误',
|
||||
wrongPasswordRemaining: '密码错误,还可尝试 {n} 次',
|
||||
tooManyAttempts: '登录尝试过多,请 {wait} 后再试',
|
||||
},
|
||||
dashboard: {
|
||||
title: '监控大盘',
|
||||
desc: '实时流量、按域名/节点分布与连接概览',
|
||||
checking: '检测中',
|
||||
engineRunning: '内核运行中',
|
||||
engineStopped: '内核未运行',
|
||||
totalNodes: '总节点',
|
||||
aliveNodes: '可用节点',
|
||||
activeConnections: '活跃连接',
|
||||
downloadRate: '下行速率',
|
||||
domainTraffic: '域名流量',
|
||||
nodeTraffic: '节点流量',
|
||||
trafficTrend: '流量趋势',
|
||||
tabLive: '当前活跃连接',
|
||||
domain: '域名',
|
||||
outboundNode: '出站节点',
|
||||
hintLiveDomain: '按域名与出站节点汇总当前活跃连接流量',
|
||||
hintPeriodDomain: '来自请求日志,按域名与出站节点聚合',
|
||||
hintLiveNode: '按具体代理节点(跳过策略组)汇总活跃连接流量',
|
||||
hintPeriodNode: '近24小时各具体代理节点完成的请求流量合计',
|
||||
seriesDownload: '下行',
|
||||
seriesUpload: '上行',
|
||||
},
|
||||
subscriptions: {
|
||||
title: '订阅管理',
|
||||
desc: '添加并定时更新 Clash 或节点链接订阅',
|
||||
add: '添加订阅',
|
||||
url: 'URL',
|
||||
nodeCount: '节点数',
|
||||
lastFetch: '上次更新',
|
||||
normal: '正常',
|
||||
error: '异常',
|
||||
refresh: '刷新',
|
||||
dialogTitle: '添加订阅',
|
||||
interval: '更新间隔',
|
||||
intervalHint: '秒,最小 300',
|
||||
typeAuto: '自动识别',
|
||||
typeClash: 'Clash YAML',
|
||||
typeLinks: '节点链接',
|
||||
typeHint: '短链 / Base64 节点订阅选「自动识别」即可',
|
||||
added: '已添加订阅',
|
||||
refreshed: '刷新成功',
|
||||
deleted: '已删除',
|
||||
confirmDelete: '确定删除订阅「{name}」?',
|
||||
},
|
||||
proxies: {
|
||||
title: '代理节点',
|
||||
desc: '查看节点状态与延迟,支持国家标签与按国家筛选',
|
||||
rebuildCountries: '重新识别国家',
|
||||
addNode: '添加节点',
|
||||
testAll: '全部测速',
|
||||
testingProgress: '测速中 {progress}',
|
||||
cancelTest: '取消测速',
|
||||
testOne: '测速',
|
||||
country: '国家',
|
||||
latency: '延迟',
|
||||
notTested: '未测',
|
||||
alive: '可用',
|
||||
filterCountry: '按国家筛选',
|
||||
allCountries: '全部国家',
|
||||
testingHint: '按序逐个测速,请勿关闭页面',
|
||||
tabAll: '全部',
|
||||
countriesRebuilt: '已重新识别 {n} 个节点国家标签',
|
||||
noTestable: '没有可测速的节点',
|
||||
testCancelled: '已取消测速',
|
||||
testDone: '测速完成',
|
||||
testDoneFailed: '测速完成,{n} 个失败',
|
||||
nodeAdded: '节点已添加',
|
||||
exported: '节点已导出',
|
||||
exportJson: 'JSON 格式',
|
||||
exportLinks: '分享链接(每行一条)',
|
||||
importTitle: '导入手动节点',
|
||||
importMode: '导入模式',
|
||||
append: '追加到现有节点',
|
||||
replace: '替换全部手动节点',
|
||||
importFile: '节点文件',
|
||||
importContent: '节点内容',
|
||||
importFileHint: '支持 JSON 或分享链接文本(ss/socks5/http/trojan)',
|
||||
importPlaceholder: '每行一条分享链接,或粘贴 JSON;# 开头为注释',
|
||||
importConfirmReplace: '将删除全部现有手动节点并替换为导入内容,确定继续?',
|
||||
importConfirmTitle: '确认导入',
|
||||
pasteRequired: '请粘贴节点或选择文件',
|
||||
imported: '已导入 {n} 个节点',
|
||||
importedPartial: '已导入 {n} 个,跳过 {s} 条',
|
||||
importFailed: '导入失败',
|
||||
addDialogTitle: '添加手动节点',
|
||||
server: '服务器',
|
||||
port: '端口',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
chooseFile: '选择文件',
|
||||
},
|
||||
outbound: {
|
||||
title: '出站管理',
|
||||
desc: '管理策略组当前节点与默认出站;规则出站请在「路由规则」页配置',
|
||||
defaultTitle: '默认出站',
|
||||
defaultHint: '未命中任何规则时使用的策略组或直连',
|
||||
save: '保存',
|
||||
countryPools: '国家池(自动测速)',
|
||||
countryPoolsHint: '按国家聚合的 url-test 组,自动选择该国最快节点',
|
||||
policyGroups: '策略组',
|
||||
countryPool: '国家池',
|
||||
currentNode: '当前节点',
|
||||
memberCount: '成员数',
|
||||
policyGroup: '策略组',
|
||||
switchNode: '切换节点',
|
||||
auto: '(自动)',
|
||||
fallbackHint: '按列表顺序主备,不通才切换(不比延迟)',
|
||||
skipDirect: '按延迟自动测速选最快节点(不含 DIRECT)',
|
||||
emptyCountry: '暂无国家池(请为节点标注国家后重载)',
|
||||
emptyPolicy: '暂无策略组',
|
||||
saved: '默认出站已保存',
|
||||
groupSwitched: '策略组已切换',
|
||||
policyGroupsHint: '仅 Selector(手动选择)类型可切换当前节点;URLTest / Fallback 由内核自动决定,无法固定某节点',
|
||||
typeSelector: '手动选择',
|
||||
typeURLTest: '自动测速',
|
||||
typeFallback: '故障转移',
|
||||
typeLoadBalance: '负载均衡',
|
||||
typeRelay: '中继',
|
||||
countryPoolName: '{name}(国家池)',
|
||||
policyGroupName: '{name}({type})',
|
||||
},
|
||||
rules: {
|
||||
title: '路由规则',
|
||||
desc: '手动规则优先级高于订阅规则;可将域名指定到某国家的自动优选节点',
|
||||
testCard: '规则命中测试',
|
||||
testPlaceholder: '输入 URL 或域名,如 https://www.google.com',
|
||||
test: '测试',
|
||||
testHint: '按合成后规则优先级模拟匹配;GEOIP/GEOSITE 需内核 Geo 数据,本地测试会跳过',
|
||||
parsedHost: '解析域名',
|
||||
matchedRule: '命中规则',
|
||||
outboundTarget: '出站目标',
|
||||
manualRules: '手动规则(高优先级)',
|
||||
mergedPreview: '合成后规则预览',
|
||||
mergedSummary: '共 {total} 条规则,兜底出站 {policy}',
|
||||
priority: '优先级',
|
||||
match: '匹配',
|
||||
target: '目标',
|
||||
rule: '规则',
|
||||
seq: '序',
|
||||
ruleAdded: '规则已添加',
|
||||
ruleDeleted: '已删除',
|
||||
confirmDelete: '确定删除该规则?',
|
||||
urlRequired: '请输入 URL 或域名',
|
||||
exported: '规则已导出',
|
||||
exportJson: 'JSON 格式',
|
||||
exportText: 'Mihomo 文本(每行一条)',
|
||||
importTitle: '导入手动规则',
|
||||
importConfirmReplace: '将删除全部现有手动规则并替换为导入内容,确定继续?',
|
||||
pasteRequired: '请粘贴规则或选择文件',
|
||||
imported: '已导入 {n} 条规则',
|
||||
importedPartial: '已导入 {n} 条,跳过 {s} 条',
|
||||
importFailed: '导入失败',
|
||||
addDialogTitle: '添加规则',
|
||||
matchValue: '匹配值',
|
||||
matchPlaceholder: '如 google.com',
|
||||
outboundType: '出站类型',
|
||||
byCountry: '按国家',
|
||||
byPolicy: '策略/直连',
|
||||
outboundTargetLabel: '出站目标',
|
||||
selectCountryPool: '选择国家节点池',
|
||||
selectPolicy: '选择策略或直连',
|
||||
countryPoolHint: '国家池使用 url-test 自动选择该国延迟最低的节点',
|
||||
importFile: '规则文件',
|
||||
importContent: '规则内容',
|
||||
importFileHint: '支持 JSON 或 Mihomo 规则文本(DOMAIN-SUFFIX,example.com,PROXY)',
|
||||
importPlaceholder: '每行一条规则,或以 JSON 格式粘贴;# 开头为注释',
|
||||
chooseFile: '选择文件',
|
||||
importMode: '导入模式',
|
||||
append: '追加到现有规则',
|
||||
replace: '替换全部手动规则',
|
||||
targetDirect: 'DIRECT(直连)',
|
||||
targetReject: 'REJECT(拒绝)',
|
||||
targetProxy: 'PROXY(默认策略组)',
|
||||
targetGlobal: 'GLOBAL(订阅总组)',
|
||||
countryFastest: '{name}(自动选最快)',
|
||||
},
|
||||
trafficLogs: {
|
||||
title: '请求日志',
|
||||
desc: '代理请求命中规则、出站节点与连接结果(连接结束后写入)',
|
||||
searchPlaceholder: '搜索域名、规则或节点',
|
||||
target: '请求目标',
|
||||
matchedRule: '命中规则',
|
||||
outboundNode: '出站节点',
|
||||
chain: '链路',
|
||||
traffic: '流量',
|
||||
result: '结果',
|
||||
pageSuccess: '本页 {ok}/{total} 成功',
|
||||
},
|
||||
logs: {
|
||||
title: '运行日志',
|
||||
desc: 'Prism 与代理内核运行日志',
|
||||
runtime: '运行日志',
|
||||
audit: '审计日志',
|
||||
searchPlaceholder: '搜索关键词',
|
||||
action: '操作',
|
||||
resource: '资源',
|
||||
detail: '详情',
|
||||
},
|
||||
settings: {
|
||||
title: '系统设置',
|
||||
desc: '代理端口、调度间隔与安全配置',
|
||||
reloadEngine: '重载内核',
|
||||
saved: '设置已保存',
|
||||
engineReloaded: '内核已重载',
|
||||
inboundApi: '入站与 API',
|
||||
proxyInbound: '代理入站',
|
||||
httpPort: 'HTTP 端口',
|
||||
socksPort: 'SOCKS5 端口',
|
||||
proxyUser: '代理用户名',
|
||||
proxyUserPlaceholder: '留空表示不启用认证',
|
||||
proxyPass: '代理密码',
|
||||
proxyPassPlaceholder: '留空则不修改;清空用户名可关闭认证',
|
||||
proxyAuthHint: '填写后 HTTP / SOCKS5 入站均需该账号密码;本机 127.0.0.1 可免认证',
|
||||
adminApi: '管理 API',
|
||||
apiPort: 'API 端口',
|
||||
apiPortHint: '修改后需重启进程生效',
|
||||
mihomoApi: 'Mihomo API',
|
||||
apiSecret: 'API Secret',
|
||||
apiSecretPlaceholder: 'Clash API 密钥',
|
||||
scheduler: '调度',
|
||||
healthInterval: '健康检查间隔',
|
||||
seconds: '秒',
|
||||
logLevel: '日志级别',
|
||||
security: '安全',
|
||||
authEnabled: '启用登录',
|
||||
loginMaxAttempts: '登录失败上限',
|
||||
loginMaxAttemptsHint: '同一 IP 在锁定时长内连续失败达到此次数后将临时锁定',
|
||||
loginLockMinutes: '登录锁定时长',
|
||||
loginLockMinutesHint: '分钟;锁定期间该 IP 无法继续尝试登录',
|
||||
newPassword: '新密码',
|
||||
newPasswordPlaceholder: '留空则不修改',
|
||||
appearance: '界面',
|
||||
uiLocale: '显示语言',
|
||||
localeZhCN: '简体中文',
|
||||
localeZhTW: '繁體中文',
|
||||
localeEn: 'English',
|
||||
apiKeys: 'API Key',
|
||||
apiKeysHint: '申请后可用 Key 直接调用管理 API,无需每次登录。请求头任选其一:',
|
||||
applyKey: '申请 API Key',
|
||||
},
|
||||
apiKey: {
|
||||
empty: '暂无 API Key',
|
||||
prefix: 'Key 前缀',
|
||||
lastUsed: '最近使用',
|
||||
createdAt: '创建时间',
|
||||
neverUsed: '从未使用',
|
||||
created: 'API Key 已创建,请立即复制保存',
|
||||
copied: '已复制到剪贴板',
|
||||
confirmRevoke: '确定吊销 Key「{name}」({prefix})?',
|
||||
revoked: '已吊销',
|
||||
dialogCreate: '申请 API Key',
|
||||
dialogSave: '请保存 API Key',
|
||||
name: '名称',
|
||||
namePlaceholder: '如 automation、ci',
|
||||
onceHint: '此 Key 只显示一次,关闭后无法再次查看完整内容。',
|
||||
},
|
||||
geo: {
|
||||
title: 'Geo 数据',
|
||||
autoDownload: '启动时自动下载',
|
||||
autoDownloadHint: '启动时若本地缺少 Geo 文件则按下方地址自动下载',
|
||||
countryMmdb: 'Country.mmdb',
|
||||
countryMmdbHint: 'GEOIP 国家匹配数据库;清空并保存可恢复内置默认地址',
|
||||
geoip: 'GEOIP',
|
||||
geoipHint: 'geoip.dat 下载地址;清空并保存可恢复内置默认地址',
|
||||
geosite: 'GEOSITE',
|
||||
geositeHint: 'geosite.dat 下载地址;清空并保存可恢复内置默认地址',
|
||||
localStatus: '本地状态',
|
||||
ready: '已就绪',
|
||||
notReady: '未就绪',
|
||||
missing: '缺失',
|
||||
downloadMissing: '下载缺失文件',
|
||||
forceUpdate: '强制更新全部',
|
||||
updateHint: '修改下载地址后请先保存,再点击更新;规则含 GEOIP/GEOSITE 时需保证三项文件齐全',
|
||||
updated: '已更新:{files}',
|
||||
upToDate: 'Geo 数据已是最新',
|
||||
updateFailed: '更新失败',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
export default {
|
||||
common: {
|
||||
prism: 'Prism',
|
||||
refresh: '重新整理',
|
||||
save: '儲存',
|
||||
cancel: '取消',
|
||||
confirm: '確認',
|
||||
search: '搜尋',
|
||||
delete: '刪除',
|
||||
import: '匯入',
|
||||
export: '匯出',
|
||||
add: '新增',
|
||||
create: '建立',
|
||||
revoke: '吊销',
|
||||
copy: '複製',
|
||||
loading: '載入中',
|
||||
unknown: '未知',
|
||||
none: '—',
|
||||
success: '成功',
|
||||
failed: '失敗',
|
||||
all: '全部',
|
||||
manual: '手動',
|
||||
subscription: '訂閱',
|
||||
source: '來源',
|
||||
name: '名稱',
|
||||
type: '類型',
|
||||
status: '狀態',
|
||||
actions: '操作',
|
||||
upload: '上行',
|
||||
download: '下行',
|
||||
connections: '連線數',
|
||||
time: '時間',
|
||||
level: '級別',
|
||||
message: '訊息',
|
||||
testing: '測速中',
|
||||
saved: '已儲存',
|
||||
empty: '暫無資料',
|
||||
management: '管理',
|
||||
logout: '登出',
|
||||
minutes: '{n} 分鐘',
|
||||
seconds: '{n} 秒',
|
||||
periodHours: '近 {n} 小時累計',
|
||||
},
|
||||
nav: {
|
||||
dashboard: '監控大盤',
|
||||
subscriptions: '訂閱管理',
|
||||
proxies: '代理節點',
|
||||
outbound: '出站管理',
|
||||
rules: '路由規則',
|
||||
trafficLogs: '請求日誌',
|
||||
logs: '執行日誌',
|
||||
settings: '系統設定',
|
||||
},
|
||||
login: {
|
||||
subtitle: 'HTTP / SOCKS5 代理閘道管理台',
|
||||
title: '登入',
|
||||
password: '密碼',
|
||||
passwordPlaceholder: '預設 admin',
|
||||
submit: '登入',
|
||||
hint: '首次使用預設密碼為 admin,請在設定中修改',
|
||||
failed: '登入失敗',
|
||||
wrongPassword: '密碼錯誤',
|
||||
wrongPasswordRemaining: '密碼錯誤,還可嘗試 {n} 次',
|
||||
tooManyAttempts: '登入嘗試過多,請 {wait} 後再試',
|
||||
},
|
||||
dashboard: {
|
||||
title: '監控大盤',
|
||||
desc: '即時流量、按網域/節點分佈與連線概覽',
|
||||
checking: '檢測中',
|
||||
engineRunning: '核心執行中',
|
||||
engineStopped: '核心未執行',
|
||||
totalNodes: '總節點',
|
||||
aliveNodes: '可用節點',
|
||||
activeConnections: '活躍連線',
|
||||
downloadRate: '下行速率',
|
||||
domainTraffic: '網域流量',
|
||||
nodeTraffic: '節點流量',
|
||||
trafficTrend: '流量趨勢',
|
||||
tabLive: '目前活躍連線',
|
||||
domain: '網域',
|
||||
outboundNode: '出站節點',
|
||||
hintLiveDomain: '按網域與出站節點彙總目前活躍連線流量',
|
||||
hintPeriodDomain: '來自請求日誌,按網域與出站節點聚合',
|
||||
hintLiveNode: '按具體代理節點(跳過策略組)彙總活躍連線流量',
|
||||
hintPeriodNode: '近24小時各具體代理節點完成的請求流量合計',
|
||||
seriesDownload: '下行',
|
||||
seriesUpload: '上行',
|
||||
},
|
||||
subscriptions: {
|
||||
title: '訂閱管理',
|
||||
desc: '新增並定時更新 Clash 或節點連結訂閱',
|
||||
add: '新增訂閱',
|
||||
url: 'URL',
|
||||
nodeCount: '節點數',
|
||||
lastFetch: '上次更新',
|
||||
normal: '正常',
|
||||
error: '異常',
|
||||
refresh: '重新整理',
|
||||
dialogTitle: '新增訂閱',
|
||||
interval: '更新間隔',
|
||||
intervalHint: '秒,最小 300',
|
||||
typeAuto: '自動識別',
|
||||
typeClash: 'Clash YAML',
|
||||
typeLinks: '節點連結',
|
||||
typeHint: '短鏈 / Base64 節點訂閱選「自動識別」即可',
|
||||
added: '已新增訂閱',
|
||||
refreshed: '重新整理成功',
|
||||
deleted: '已刪除',
|
||||
confirmDelete: '確定刪除訂閱「{name}」?',
|
||||
},
|
||||
proxies: {
|
||||
title: '代理節點',
|
||||
desc: '查看節點狀態與延遲,支援國家標籤與按國家篩選',
|
||||
rebuildCountries: '重新識別國家',
|
||||
addNode: '新增節點',
|
||||
testAll: '全部測速',
|
||||
testingProgress: '測速中 {progress}',
|
||||
cancelTest: '取消測速',
|
||||
testOne: '測速',
|
||||
country: '國家',
|
||||
latency: '延遲',
|
||||
notTested: '未測',
|
||||
alive: '可用',
|
||||
filterCountry: '按國家篩選',
|
||||
allCountries: '全部國家',
|
||||
testingHint: '按序逐個測速,請勿關閉頁面',
|
||||
tabAll: '全部',
|
||||
countriesRebuilt: '已重新識別 {n} 個節點國家標籤',
|
||||
noTestable: '沒有可測速的節點',
|
||||
testCancelled: '已取消測速',
|
||||
testDone: '測速完成',
|
||||
testDoneFailed: '測速完成,{n} 個失敗',
|
||||
nodeAdded: '節點已新增',
|
||||
exported: '節點已匯出',
|
||||
exportJson: 'JSON 格式',
|
||||
exportLinks: '分享連結(每行一條)',
|
||||
importTitle: '匯入手動節點',
|
||||
importMode: '匯入模式',
|
||||
append: '追加到現有節點',
|
||||
replace: '替換全部手動節點',
|
||||
importFile: '節點檔案',
|
||||
importContent: '節點內容',
|
||||
importFileHint: '支援 JSON 或分享連結文字(ss/socks5/http/trojan)',
|
||||
importPlaceholder: '每行一條分享連結,或貼上 JSON;# 開頭為註解',
|
||||
importConfirmReplace: '將刪除全部現有手動節點並替換為匯入內容,確定繼續?',
|
||||
importConfirmTitle: '確認匯入',
|
||||
pasteRequired: '請貼上節點或選擇檔案',
|
||||
imported: '已匯入 {n} 個節點',
|
||||
importedPartial: '已匯入 {n} 個,跳過 {s} 條',
|
||||
importFailed: '匯入失敗',
|
||||
addDialogTitle: '新增手動節點',
|
||||
server: '伺服器',
|
||||
port: '埠',
|
||||
username: '使用者名稱',
|
||||
password: '密碼',
|
||||
chooseFile: '選擇檔案',
|
||||
},
|
||||
outbound: {
|
||||
title: '出站管理',
|
||||
desc: '管理策略組目前節點與預設出站;規則出站請在「路由規則」頁設定',
|
||||
defaultTitle: '預設出站',
|
||||
defaultHint: '未命中任何規則時使用的策略組或直連',
|
||||
save: '儲存',
|
||||
countryPools: '國家池(自動測速)',
|
||||
countryPoolsHint: '按國家聚合的 url-test 組,自動選擇該國最快節點',
|
||||
policyGroups: '策略組',
|
||||
countryPool: '國家池',
|
||||
currentNode: '目前節點',
|
||||
memberCount: '成員數',
|
||||
policyGroup: '策略組',
|
||||
switchNode: '切換節點',
|
||||
auto: '(自動)',
|
||||
fallbackHint: '按列表順序主備,不通才切換(不比延遲)',
|
||||
skipDirect: '按延遲自動測速選最快節點(不含 DIRECT)',
|
||||
emptyCountry: '暫無國家池(請為節點標註國家後重載)',
|
||||
emptyPolicy: '暫無策略組',
|
||||
saved: '預設出站已儲存',
|
||||
groupSwitched: '策略組已切換',
|
||||
policyGroupsHint: '僅 Selector(手動選擇)類型可切換目前節點;URLTest / Fallback 由核心自動決定,無法固定某節點',
|
||||
typeSelector: '手動選擇',
|
||||
typeURLTest: '自動測速',
|
||||
typeFallback: '故障轉移',
|
||||
typeLoadBalance: '負載均衡',
|
||||
typeRelay: '中繼',
|
||||
countryPoolName: '{name}(國家池)',
|
||||
policyGroupName: '{name}({type})',
|
||||
},
|
||||
rules: {
|
||||
title: '路由規則',
|
||||
desc: '手動規則優先級高於訂閱規則;可將網域指定到某國家的自動優選節點',
|
||||
testCard: '規則命中測試',
|
||||
testPlaceholder: '輸入 URL 或網域,如 https://www.google.com',
|
||||
test: '測試',
|
||||
testHint: '按合成後規則優先級模擬匹配;GEOIP/GEOSITE 需核心 Geo 資料,本地測試會跳過',
|
||||
parsedHost: '解析網域',
|
||||
matchedRule: '命中規則',
|
||||
outboundTarget: '出站目標',
|
||||
manualRules: '手動規則(高優先級)',
|
||||
mergedPreview: '合成後規則預覽',
|
||||
mergedSummary: '共 {total} 條規則,兜底出站 {policy}',
|
||||
priority: '優先級',
|
||||
match: '匹配',
|
||||
target: '目標',
|
||||
rule: '規則',
|
||||
seq: '序',
|
||||
ruleAdded: '規則已新增',
|
||||
ruleDeleted: '已刪除',
|
||||
confirmDelete: '確定刪除該規則?',
|
||||
urlRequired: '請輸入 URL 或網域',
|
||||
exported: '規則已匯出',
|
||||
exportJson: 'JSON 格式',
|
||||
exportText: 'Mihomo 文字(每行一條)',
|
||||
importTitle: '匯入手動規則',
|
||||
importConfirmReplace: '將刪除全部現有手動規則並替換為匯入內容,確定繼續?',
|
||||
pasteRequired: '請貼上規則或選擇檔案',
|
||||
imported: '已匯入 {n} 條規則',
|
||||
importedPartial: '已匯入 {n} 條,跳過 {s} 條',
|
||||
importFailed: '匯入失敗',
|
||||
addDialogTitle: '新增規則',
|
||||
matchValue: '匹配值',
|
||||
matchPlaceholder: '如 google.com',
|
||||
outboundType: '出站類型',
|
||||
byCountry: '按國家',
|
||||
byPolicy: '策略/直連',
|
||||
outboundTargetLabel: '出站目標',
|
||||
selectCountryPool: '選擇國家節點池',
|
||||
selectPolicy: '選擇策略或直連',
|
||||
countryPoolHint: '國家池使用 url-test 自動選擇該國延遲最低的節點',
|
||||
importFile: '規則檔案',
|
||||
importContent: '規則內容',
|
||||
importFileHint: '支援 JSON 或 Mihomo 規則文字(DOMAIN-SUFFIX,example.com,PROXY)',
|
||||
importPlaceholder: '每行一條規則,或以 JSON 格式貼上;# 開頭為註解',
|
||||
chooseFile: '選擇檔案',
|
||||
importMode: '匯入模式',
|
||||
append: '追加到現有規則',
|
||||
replace: '替換全部手動規則',
|
||||
targetDirect: 'DIRECT(直連)',
|
||||
targetReject: 'REJECT(拒絕)',
|
||||
targetProxy: 'PROXY(預設策略組)',
|
||||
targetGlobal: 'GLOBAL(訂閱總組)',
|
||||
countryFastest: '{name}(自動選最快)',
|
||||
},
|
||||
trafficLogs: {
|
||||
title: '請求日誌',
|
||||
desc: '代理請求命中規則、出站節點與連線結果(連線結束後寫入)',
|
||||
searchPlaceholder: '搜尋網域、規則或節點',
|
||||
target: '請求目標',
|
||||
matchedRule: '命中規則',
|
||||
outboundNode: '出站節點',
|
||||
chain: '鏈路',
|
||||
traffic: '流量',
|
||||
result: '結果',
|
||||
pageSuccess: '本頁 {ok}/{total} 成功',
|
||||
},
|
||||
logs: {
|
||||
title: '執行日誌',
|
||||
desc: 'Prism 與代理核心執行日誌',
|
||||
runtime: '執行日誌',
|
||||
audit: '稽核日誌',
|
||||
searchPlaceholder: '搜尋關鍵詞',
|
||||
action: '操作',
|
||||
resource: '資源',
|
||||
detail: '詳情',
|
||||
},
|
||||
settings: {
|
||||
title: '系統設定',
|
||||
desc: '代理埠、排程間隔與安全配置',
|
||||
reloadEngine: '重載核心',
|
||||
saved: '設定已儲存',
|
||||
engineReloaded: '核心已重載',
|
||||
inboundApi: '入站與 API',
|
||||
proxyInbound: '代理入站',
|
||||
httpPort: 'HTTP 埠',
|
||||
socksPort: 'SOCKS5 埠',
|
||||
proxyUser: '代理使用者名稱',
|
||||
proxyUserPlaceholder: '留空表示不啟用認證',
|
||||
proxyPass: '代理密碼',
|
||||
proxyPassPlaceholder: '留空則不修改;清空使用者名稱可關閉認證',
|
||||
proxyAuthHint: '填寫後 HTTP / SOCKS5 入站均需該帳號密碼;本機 127.0.0.1 可免認證',
|
||||
adminApi: '管理 API',
|
||||
apiPort: 'API 埠',
|
||||
apiPortHint: '修改後需重啟程序生效',
|
||||
mihomoApi: 'Mihomo API',
|
||||
apiSecret: 'API Secret',
|
||||
apiSecretPlaceholder: 'Clash API 密鑰',
|
||||
scheduler: '排程',
|
||||
healthInterval: '健康檢查間隔',
|
||||
seconds: '秒',
|
||||
logLevel: '日誌級別',
|
||||
security: '安全',
|
||||
authEnabled: '啟用登入',
|
||||
loginMaxAttempts: '登入失敗上限',
|
||||
loginMaxAttemptsHint: '同一 IP 在鎖定時長內連續失敗達到此次數後將暫時鎖定',
|
||||
loginLockMinutes: '登入鎖定時長',
|
||||
loginLockMinutesHint: '分鐘;鎖定期間該 IP 無法繼續嘗試登入',
|
||||
newPassword: '新密碼',
|
||||
newPasswordPlaceholder: '留空則不修改',
|
||||
appearance: '介面',
|
||||
uiLocale: '顯示語言',
|
||||
localeZhCN: '简体中文',
|
||||
localeZhTW: '繁體中文',
|
||||
localeEn: 'English',
|
||||
apiKeys: 'API Key',
|
||||
apiKeysHint: '申請後可用 Key 直接呼叫管理 API,無需每次登入。請求標頭任選其一:',
|
||||
applyKey: '申請 API Key',
|
||||
},
|
||||
apiKey: {
|
||||
empty: '暫無 API Key',
|
||||
prefix: 'Key 前綴',
|
||||
lastUsed: '最近使用',
|
||||
createdAt: '建立時間',
|
||||
neverUsed: '從未使用',
|
||||
created: 'API Key 已建立,請立即複製儲存',
|
||||
copied: '已複製到剪貼簿',
|
||||
confirmRevoke: '確定吊销 Key「{name}」({prefix})?',
|
||||
revoked: '已吊销',
|
||||
dialogCreate: '申請 API Key',
|
||||
dialogSave: '請儲存 API Key',
|
||||
name: '名稱',
|
||||
namePlaceholder: '如 automation、ci',
|
||||
onceHint: '此 Key 只顯示一次,關閉後無法再次查看完整內容。',
|
||||
},
|
||||
geo: {
|
||||
title: 'Geo 資料',
|
||||
autoDownload: '啟動時自動下載',
|
||||
autoDownloadHint: '啟動時若本地缺少 Geo 檔案則按下方位址自動下載',
|
||||
countryMmdb: 'Country.mmdb',
|
||||
countryMmdbHint: 'GEOIP 國家匹配資料庫;清空並儲存可恢復內建預設位址',
|
||||
geoip: 'GEOIP',
|
||||
geoipHint: 'geoip.dat 下載位址;清空並儲存可恢復內建預設位址',
|
||||
geosite: 'GEOSITE',
|
||||
geositeHint: 'geosite.dat 下載位址;清空並儲存可恢復內建預設位址',
|
||||
localStatus: '本地狀態',
|
||||
ready: '已就緒',
|
||||
notReady: '未就緒',
|
||||
missing: '缺失',
|
||||
downloadMissing: '下載缺失檔案',
|
||||
forceUpdate: '強制更新全部',
|
||||
updateHint: '修改下載位址後請先儲存,再點擊更新;規則含 GEOIP/GEOSITE 時需保證三項檔案齊全',
|
||||
updated: '已更新:{files}',
|
||||
upToDate: 'Geo 資料已是最新',
|
||||
updateFailed: '更新失敗',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type LocaleCode = 'zh-CN' | 'zh-TW' | 'en'
|
||||
|
||||
export const SUPPORTED_LOCALES: { value: LocaleCode; labelKey: string }[] = [
|
||||
{ value: 'zh-CN', labelKey: 'settings.localeZhCN' },
|
||||
{ value: 'zh-TW', labelKey: 'settings.localeZhTW' },
|
||||
{ value: 'en', labelKey: 'settings.localeEn' },
|
||||
]
|
||||
|
||||
export function isLocaleCode(v: string): v is LocaleCode {
|
||||
return v === 'zh-CN' || v === 'zh-TW' || v === 'en'
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
Odometer, Link, Connection, List, Document, Setting, Switch, Tickets,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import PrismIcon from '@/components/PrismIcon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const navKeys = [
|
||||
{ path: '/', key: 'nav.dashboard', icon: Odometer },
|
||||
{ path: '/subscriptions', key: 'nav.subscriptions', icon: Link },
|
||||
{ path: '/proxies', key: 'nav.proxies', icon: Connection },
|
||||
{ path: '/outbound', key: 'nav.outbound', icon: Switch },
|
||||
{ path: '/rules', key: 'nav.rules', icon: List },
|
||||
{ path: '/traffic-logs', key: 'nav.trafficLogs', icon: Tickets },
|
||||
{ path: '/logs', key: 'nav.logs', icon: Document },
|
||||
{ path: '/settings', key: 'nav.settings', icon: Setting },
|
||||
] as const
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const hit = navKeys.find((item) => item.path === route.path)
|
||||
return hit ? t(hit.key) : 'Prism'
|
||||
})
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside class="sidebar custom-scrollbar">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-icon">
|
||||
<PrismIcon :size="20" />
|
||||
</div>
|
||||
<span class="brand-name">Prism</span>
|
||||
</div>
|
||||
<div class="nav-group-label">{{ t('common.management') }}</div>
|
||||
<router-link
|
||||
v-for="item in navKeys"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="nav-item"
|
||||
:class="{ active: route.path === item.path }"
|
||||
>
|
||||
<el-icon :size="16"><component :is="item.icon" /></el-icon>
|
||||
{{ t(item.key) }}
|
||||
</router-link>
|
||||
</aside>
|
||||
<div class="main-area">
|
||||
<header class="topbar">
|
||||
<span class="topbar-title">{{ pageTitle }}</span>
|
||||
<el-button text @click="logout">{{ t('common.logout') }}</el-button>
|
||||
</header>
|
||||
<main class="main-content custom-scrollbar">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/theme.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n, { applyDocumentLocale } from '@/i18n'
|
||||
import { useLocaleStore } from '@/stores/locale'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
app.use(ElementPlus)
|
||||
|
||||
const localeStore = useLocaleStore(pinia)
|
||||
applyDocumentLocale(localeStore.locale)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import MainLayout from '@/layouts/MainLayout.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayout,
|
||||
children: [
|
||||
{ path: '', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
|
||||
{ path: 'subscriptions', name: 'subscriptions', component: () => import('@/views/SubscriptionsView.vue') },
|
||||
{ path: 'proxies', name: 'proxies', component: () => import('@/views/ProxiesView.vue') },
|
||||
{ path: 'outbound', name: 'outbound', component: () => import('@/views/OutboundView.vue') },
|
||||
{ path: 'rules', name: 'rules', component: () => import('@/views/RulesView.vue') },
|
||||
{ path: 'traffic-logs', name: 'traffic-logs', component: () => import('@/views/TrafficLogsView.vue') },
|
||||
{ path: 'logs', name: 'logs', component: () => import('@/views/LogsView.vue') },
|
||||
{ path: 'settings', name: 'settings', component: () => import('@/views/SettingsView.vue') },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (to.meta.public) return true
|
||||
if (auth.isLoggedIn()) return true
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/status')
|
||||
const data = await res.json()
|
||||
if (!data.enabled) {
|
||||
auth.token = 'disabled'
|
||||
return true
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return '/login'
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '@/api/client'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('prism_token') || '')
|
||||
|
||||
async function login(password: string) {
|
||||
const { data } = await api.post('/auth/login', { password })
|
||||
token.value = data.token
|
||||
localStorage.setItem('prism_token', data.token)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
localStorage.removeItem('prism_token')
|
||||
}
|
||||
|
||||
const isLoggedIn = () => !!token.value
|
||||
|
||||
return { token, login, logout, isLoggedIn }
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { elementLocales, LOCALE_STORAGE_KEY, setI18nLocale } from '@/i18n'
|
||||
import { isLocaleCode, SUPPORTED_LOCALES, type LocaleCode } from '@/i18n/types'
|
||||
|
||||
export const useLocaleStore = defineStore('locale', {
|
||||
state: () => ({
|
||||
locale: (localStorage.getItem(LOCALE_STORAGE_KEY) || 'zh-CN') as LocaleCode,
|
||||
}),
|
||||
getters: {
|
||||
elementLocale: (state) => elementLocales[state.locale],
|
||||
options: () => SUPPORTED_LOCALES,
|
||||
},
|
||||
actions: {
|
||||
setLocale(locale: LocaleCode, persist = true) {
|
||||
if (!isLocaleCode(locale)) return
|
||||
this.locale = locale
|
||||
setI18nLocale(locale)
|
||||
if (persist) {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, locale)
|
||||
}
|
||||
},
|
||||
applyFromSettings(uiLocale?: string) {
|
||||
if (uiLocale && isLocaleCode(uiLocale)) {
|
||||
this.setLocale(uiLocale)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
:root {
|
||||
--background: #f4f4f5;
|
||||
--foreground: #18181b;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #18181b;
|
||||
--primary: #0f7a62;
|
||||
--primary-hover: #0d6b56;
|
||||
--primary-foreground: #fafafa;
|
||||
--muted: #f4f4f5;
|
||||
--muted-foreground: #71717a;
|
||||
--border: #e4e4e7;
|
||||
--input: #e4e4e7;
|
||||
--destructive: #dc2626;
|
||||
--success: #047857;
|
||||
--warning: #b45309;
|
||||
--danger: #dc2626;
|
||||
--info: #a1a1aa;
|
||||
--sidebar: rgba(250, 250, 250, 0.85);
|
||||
--sidebar-foreground: #18181b;
|
||||
--sidebar-accent: #f4f4f5;
|
||||
--sidebar-border: #e4e4e7;
|
||||
--sidebar-width: 15rem;
|
||||
--radius: 0.5rem;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
--el-color-primary: #0f7a62;
|
||||
--el-color-primary-light-3: #4da08c;
|
||||
--el-color-primary-light-5: #76b8a8;
|
||||
--el-color-primary-light-7: #a0d0c4;
|
||||
--el-color-primary-light-8: #b8dcd2;
|
||||
--el-color-primary-light-9: #d0e8e1;
|
||||
--el-color-primary-dark-2: #0c624e;
|
||||
--el-color-success: #047857;
|
||||
--el-color-warning: #b45309;
|
||||
--el-color-danger: #dc2626;
|
||||
--el-color-info: #a1a1aa;
|
||||
--el-border-radius-base: 0.5rem;
|
||||
--el-border-color: #e4e4e7;
|
||||
--el-bg-color-page: #f4f4f5;
|
||||
--el-text-color-primary: #18181b;
|
||||
--el-text-color-regular: #3f3f46;
|
||||
--el-text-color-secondary: #71717a;
|
||||
--el-font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
font-family: var(--el-font-family);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
background: var(--sidebar);
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-5) var(--space-4);
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-group-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted-foreground);
|
||||
padding: var(--space-4) var(--space-4) var(--space-2);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
margin: 0 var(--space-2);
|
||||
border-radius: var(--radius);
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item:hover { background: var(--sidebar-accent); }
|
||||
.nav-item.active {
|
||||
background: var(--sidebar-accent);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-6);
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-6);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 var(--space-1);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.stat-card__label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.stat-card__value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.page > .app-card:last-child,
|
||||
.page > .content-grid:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.app-card__header {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.app-card__title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-card__body--padded { padding: var(--space-5); }
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.content-grid--2-1 { grid-template-columns: 1.4fr 1fr; }
|
||||
.content-grid--2 { grid-template-columns: 1fr 1fr; }
|
||||
.content-grid--3 { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
.card-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-5) 0;
|
||||
margin-bottom: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-toolbar--desc {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-pager {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.card-footer-hint {
|
||||
padding: 0 var(--space-5) var(--space-3);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-footer-hint + .card-pager {
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
.card-inset-block {
|
||||
margin: 0 var(--space-5) var(--space-4);
|
||||
padding: var(--space-3);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
padding: 0 var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tabs-bar .el-tabs__header { margin-bottom: 0; }
|
||||
|
||||
.page-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.form-hint--flush { margin-top: 0; }
|
||||
|
||||
.u-mt-3 { margin-top: var(--space-3); }
|
||||
.u-mt-4 { margin-top: var(--space-4); }
|
||||
.u-mb-3 { margin-bottom: var(--space-3); }
|
||||
.u-mb-4 { margin-bottom: var(--space-4); }
|
||||
.u-mb-6 { margin-bottom: var(--space-6); }
|
||||
.text-center { text-align: center; }
|
||||
.w-full { width: 100%; }
|
||||
|
||||
code.inline-code {
|
||||
font-size: 0.75rem;
|
||||
padding: 0 var(--space-1);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.section-title:not(:first-child) { margin-top: var(--space-6); }
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--background);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.login-wrap { max-width: 400px; width: 100%; text-align: center; }
|
||||
|
||||
.login-brand-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-8);
|
||||
margin-top: var(--space-6);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.latency-good { color: #047857; }
|
||||
.latency-warn { color: #b45309; }
|
||||
.latency-bad { color: #dc2626; }
|
||||
.latency-dead { color: var(--muted-foreground); }
|
||||
|
||||
/* Toolbar field widths */
|
||||
.toolbar-select { width: 120px; }
|
||||
.toolbar-select--country { width: 160px; }
|
||||
.toolbar-select--wide { width: 280px; }
|
||||
.toolbar-input { width: 200px; }
|
||||
.toolbar-input--wide { width: 260px; }
|
||||
.toolbar-input--grow { flex: 1; min-width: 200px; }
|
||||
.table-cell-select { width: 100%; min-width: 200px; }
|
||||
|
||||
/* Forms */
|
||||
.el-form .el-select { width: 100%; }
|
||||
|
||||
/* Tables in cards */
|
||||
.app-card .el-table {
|
||||
--el-table-header-bg-color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.app-card .el-table--small .el-table__cell {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.app-card .el-table--small th.el-table__cell {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.app-card .el-table--small td.el-table__cell {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.app-card .el-table--small .el-table__row {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.app-card .el-table .el-button.is-link {
|
||||
padding: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.app-card .el-table .cell {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.table-scroll--sm { max-height: 320px; }
|
||||
.table-scroll--md { max-height: 360px; }
|
||||
.table-scroll--lg { max-height: 480px; }
|
||||
|
||||
.card-toolbar .toolbar-input--grow {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.card-pager .el-pagination {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.test-result p {
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
.test-result p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.test-result .label {
|
||||
display: inline-block;
|
||||
width: 5rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.test-result code {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.geo-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.geo-file-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.geo-file-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.geo-file-name {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.geo-file-ok { color: var(--el-color-success); }
|
||||
.geo-file-miss { color: var(--el-color-warning); }
|
||||
|
||||
.chart-box {
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #e4e4e7 transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #e4e4e7;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.content-grid--2-1,
|
||||
.content-grid--2,
|
||||
.content-grid--3 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stat-grid { grid-template-columns: 1fr; }
|
||||
.main-content { padding: var(--space-4); }
|
||||
.page-header { flex-direction: column; }
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import * as echarts from 'echarts'
|
||||
import api from '@/api/client'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import StatCard from '@/components/StatCard.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface TrafficItem {
|
||||
key: string
|
||||
label: string
|
||||
node?: string
|
||||
node_key?: string
|
||||
upload: number
|
||||
download: number
|
||||
total: number
|
||||
connections: number
|
||||
}
|
||||
|
||||
const snapshot = ref({
|
||||
upload_rate: 0,
|
||||
download_rate: 0,
|
||||
connections: 0,
|
||||
total_nodes: 0,
|
||||
alive_nodes: 0,
|
||||
})
|
||||
const liveDomains = ref<TrafficItem[]>([])
|
||||
const liveNodes = ref<TrafficItem[]>([])
|
||||
const periodDomains = ref<TrafficItem[]>([])
|
||||
const periodNodes = ref<TrafficItem[]>([])
|
||||
const periodHours = ref(24)
|
||||
const domainTab = ref('live')
|
||||
const nodeTab = ref('live')
|
||||
const domainPage = ref(1)
|
||||
const domainPageSize = ref(50)
|
||||
const domainTotal = ref(0)
|
||||
const nodePage = ref(1)
|
||||
const nodePageSize = ref(50)
|
||||
const nodeTotal = ref(0)
|
||||
const engineRunning = ref<boolean | null>(null)
|
||||
const chartRef = ref<HTMLElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
let timer: number
|
||||
|
||||
const domainRows = computed(() => (domainTab.value === 'live' ? liveDomains.value : periodDomains.value))
|
||||
const nodeRows = computed(() => (nodeTab.value === 'live' ? liveNodes.value : periodNodes.value))
|
||||
|
||||
const CHART_AXIS = { color: '#71717a', fontSize: 12 }
|
||||
const CHART_LINE = { color: '#e4e4e7' }
|
||||
|
||||
function formatRate(v: number) {
|
||||
if (v < 1024) return `${v} B/s`
|
||||
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB/s`
|
||||
return `${(v / 1024 / 1024).toFixed(1)} MB/s`
|
||||
}
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(2)} MB`
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
function dashboardParams() {
|
||||
const params: Record<string, number> = {}
|
||||
if (domainTab.value === 'live') {
|
||||
params.live_domain_limit = domainPageSize.value
|
||||
params.live_domain_offset = (domainPage.value - 1) * domainPageSize.value
|
||||
} else {
|
||||
params.period_domain_limit = domainPageSize.value
|
||||
params.period_domain_offset = (domainPage.value - 1) * domainPageSize.value
|
||||
}
|
||||
if (nodeTab.value === 'live') {
|
||||
params.live_node_limit = nodePageSize.value
|
||||
params.live_node_offset = (nodePage.value - 1) * nodePageSize.value
|
||||
} else {
|
||||
params.period_node_limit = nodePageSize.value
|
||||
params.period_node_offset = (nodePage.value - 1) * nodePageSize.value
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { data } = await api.get('/dashboard', { params: dashboardParams() })
|
||||
snapshot.value = data.snapshot
|
||||
engineRunning.value = data.engine_running
|
||||
liveDomains.value = data.live_domains || []
|
||||
liveNodes.value = data.live_nodes || []
|
||||
periodDomains.value = data.period_domains || []
|
||||
periodNodes.value = data.period_nodes || []
|
||||
periodHours.value = data.period_hours || 24
|
||||
domainTotal.value = domainTab.value === 'live' ? (data.live_domain_total || 0) : (data.period_domain_total || 0)
|
||||
nodeTotal.value = nodeTab.value === 'live' ? (data.live_node_total || 0) : (data.period_node_total || 0)
|
||||
const history = (data.history || []).reverse()
|
||||
if (chart && history.length) {
|
||||
chart.setOption({
|
||||
grid: { left: 40, right: 16, top: 16, bottom: 24 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: history.map((h: { created_at?: string }) => h.created_at?.slice(11, 19) || ''),
|
||||
axisLabel: CHART_AXIS,
|
||||
axisLine: { lineStyle: CHART_LINE },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: CHART_AXIS,
|
||||
splitLine: { lineStyle: CHART_LINE },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('dashboard.seriesDownload'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
lineStyle: { width: 2 },
|
||||
data: history.map((h: { download_rate: number }) => h.download_rate),
|
||||
color: '#0f7a62',
|
||||
},
|
||||
{
|
||||
name: t('dashboard.seriesUpload'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
lineStyle: { width: 2 },
|
||||
data: history.map((h: { upload_rate: number }) => h.upload_rate),
|
||||
color: '#71717a',
|
||||
},
|
||||
],
|
||||
tooltip: { trigger: 'axis' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resetDomainPage() {
|
||||
domainPage.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function resetNodePage() {
|
||||
nodePage.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
watch(domainTab, resetDomainPage)
|
||||
watch(nodeTab, resetNodePage)
|
||||
|
||||
onMounted(() => {
|
||||
if (chartRef.value) {
|
||||
chart = echarts.init(chartRef.value)
|
||||
}
|
||||
load()
|
||||
timer = window.setInterval(load, 10000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
chart?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader :title="t('dashboard.title')" :description="t('dashboard.desc')">
|
||||
<template #actions>
|
||||
<el-tag v-if="engineRunning === null" type="info" size="small">{{ t('dashboard.checking') }}</el-tag>
|
||||
<el-tag v-else :type="engineRunning ? 'success' : 'danger'" size="small">
|
||||
{{ engineRunning ? t('dashboard.engineRunning') : t('dashboard.engineStopped') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="stat-grid">
|
||||
<StatCard :label="t('dashboard.totalNodes')" :value="snapshot.total_nodes" />
|
||||
<StatCard :label="t('dashboard.aliveNodes')" :value="snapshot.alive_nodes" />
|
||||
<StatCard :label="t('dashboard.activeConnections')" :value="snapshot.connections" />
|
||||
<StatCard :label="t('dashboard.downloadRate')" :value="formatRate(snapshot.download_rate)" />
|
||||
</div>
|
||||
|
||||
<div class="content-grid content-grid--2-1">
|
||||
<AppCard :title="t('dashboard.domainTraffic')">
|
||||
<div class="tabs-bar">
|
||||
<el-tabs v-model="domainTab">
|
||||
<el-tab-pane :label="t('dashboard.tabLive')" name="live" />
|
||||
<el-tab-pane :label="t('common.periodHours', { n: periodHours })" name="period" />
|
||||
</el-tabs>
|
||||
</div>
|
||||
<el-table
|
||||
:data="domainRows"
|
||||
size="small"
|
||||
class="table-scroll--sm"
|
||||
:empty-text="t('common.empty')"
|
||||
>
|
||||
<el-table-column prop="label" :label="t('dashboard.domain')" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="node" :label="t('dashboard.outboundNode')" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column :label="t('common.upload')" width="100">
|
||||
<template #default="{ row }">{{ formatBytes(row.upload) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.download')" width="100">
|
||||
<template #default="{ row }">{{ formatBytes(row.download) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.connections')" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.connections }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p class="form-hint form-hint--flush card-footer-hint">
|
||||
{{ domainTab === 'live' ? t('dashboard.hintLiveDomain') : t('dashboard.hintPeriodDomain') }}
|
||||
</p>
|
||||
<div class="card-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="domainPage"
|
||||
v-model:page-size="domainPageSize"
|
||||
:page-sizes="[50, 100, 200, 500]"
|
||||
:total="domainTotal"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="resetDomainPage"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard :title="t('dashboard.trafficTrend')" padded>
|
||||
<div ref="chartRef" class="chart-box" />
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard :title="t('dashboard.nodeTraffic')">
|
||||
<div class="tabs-bar">
|
||||
<el-tabs v-model="nodeTab">
|
||||
<el-tab-pane :label="t('dashboard.tabLive')" name="live" />
|
||||
<el-tab-pane :label="t('common.periodHours', { n: periodHours })" name="period" />
|
||||
</el-tabs>
|
||||
</div>
|
||||
<el-table
|
||||
:data="nodeRows"
|
||||
size="small"
|
||||
class="table-scroll--md"
|
||||
:empty-text="t('common.empty')"
|
||||
>
|
||||
<el-table-column prop="label" :label="t('dashboard.outboundNode')" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column :label="t('common.upload')" width="100">
|
||||
<template #default="{ row }">{{ formatBytes(row.upload) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.download')" width="100">
|
||||
<template #default="{ row }">{{ formatBytes(row.download) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.connections')" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.connections }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<p class="form-hint form-hint--flush card-footer-hint">
|
||||
{{ nodeTab === 'live' ? t('dashboard.hintLiveNode') : t('dashboard.hintPeriodNode') }}
|
||||
</p>
|
||||
<div class="card-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="nodePage"
|
||||
v-model:page-size="nodePageSize"
|
||||
:page-sizes="[50, 100, 200, 500]"
|
||||
:total="nodeTotal"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="resetNodePage"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import PrismIcon from '@/components/PrismIcon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
function formatRetryAfter(seconds: number) {
|
||||
if (seconds >= 60) {
|
||||
return t('common.minutes', { n: Math.ceil(seconds / 60) })
|
||||
}
|
||||
return t('common.seconds', { n: seconds })
|
||||
}
|
||||
|
||||
function loginErrorMessage(err: unknown) {
|
||||
if (!isAxiosError(err)) return t('login.failed')
|
||||
const status = err.response?.status
|
||||
const data = err.response?.data as { retry_after?: number; remaining?: number } | undefined
|
||||
if (status === 429) {
|
||||
return t('login.tooManyAttempts', { wait: formatRetryAfter(data?.retry_after ?? 60) })
|
||||
}
|
||||
if (typeof data?.remaining === 'number' && data.remaining > 0) {
|
||||
return t('login.wrongPasswordRemaining', { n: data.remaining })
|
||||
}
|
||||
return t('login.wrongPassword')
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(password.value)
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
ElMessage.error(loginErrorMessage(err))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-wrap">
|
||||
<div class="login-brand-icon">
|
||||
<PrismIcon :size="24" />
|
||||
</div>
|
||||
<h1 class="page-title">Prism</h1>
|
||||
<p class="page-desc">{{ t('login.subtitle') }}</p>
|
||||
<div class="login-card">
|
||||
<h3 class="app-card__title u-mb-6">{{ t('login.title') }}</h3>
|
||||
<el-form size="large" @submit.prevent="submit">
|
||||
<el-form-item :label="t('login.password')">
|
||||
<el-input v-model="password" type="password" show-password :placeholder="t('login.passwordPlaceholder')" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" native-type="submit" :loading="loading" class="w-full">
|
||||
{{ t('login.submit') }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
<p class="form-hint u-mt-4 text-center">
|
||||
{{ t('login.hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api/client'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const items = ref<any[]>([])
|
||||
const auditItems = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const level = ref('')
|
||||
const keyword = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const auditPage = ref(1)
|
||||
const auditPageSize = ref(50)
|
||||
const auditTotal = ref(0)
|
||||
const activeTab = ref('runtime')
|
||||
|
||||
function resetAndLoad() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (activeTab.value === 'audit') {
|
||||
const { data } = await api.get('/audit-logs', {
|
||||
params: {
|
||||
limit: auditPageSize.value,
|
||||
offset: (auditPage.value - 1) * auditPageSize.value,
|
||||
},
|
||||
})
|
||||
auditItems.value = data.items || []
|
||||
auditTotal.value = data.total || 0
|
||||
return
|
||||
}
|
||||
const { data } = await api.get('/logs', {
|
||||
params: {
|
||||
level: level.value,
|
||||
keyword: keyword.value,
|
||||
limit: pageSize.value,
|
||||
offset: (page.value - 1) * pageSize.value,
|
||||
},
|
||||
})
|
||||
items.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange() {
|
||||
page.value = 1
|
||||
auditPage.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader :title="t('logs.title')" :description="t('logs.desc')">
|
||||
<template #actions>
|
||||
<el-button @click="load">{{ t('common.refresh') }}</el-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<AppCard>
|
||||
<div class="tabs-bar">
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<el-tab-pane :label="t('logs.runtime')" name="runtime" />
|
||||
<el-tab-pane :label="t('logs.audit')" name="audit" />
|
||||
</el-tabs>
|
||||
</div>
|
||||
<div v-if="activeTab === 'runtime'" class="card-toolbar">
|
||||
<el-select v-model="level" :placeholder="t('common.level')" clearable class="toolbar-select" @change="resetAndLoad">
|
||||
<el-option label="info" value="info" />
|
||||
<el-option label="warn" value="warn" />
|
||||
<el-option label="error" value="error" />
|
||||
</el-select>
|
||||
<el-input v-model="keyword" :placeholder="t('logs.searchPlaceholder')" clearable class="toolbar-input" @keyup.enter="resetAndLoad" />
|
||||
<el-button type="primary" @click="resetAndLoad">{{ t('common.search') }}</el-button>
|
||||
</div>
|
||||
<el-table v-if="activeTab === 'runtime'" :data="items" v-loading="loading" size="small" class="table-scroll--lg">
|
||||
<el-table-column prop="created_at" :label="t('common.time')" width="168" />
|
||||
<el-table-column prop="level" :label="t('common.level')" width="80" />
|
||||
<el-table-column prop="source" :label="t('common.source')" width="100" />
|
||||
<el-table-column prop="message" :label="t('common.message')" min-width="300" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div v-if="activeTab === 'runtime'" class="card-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[50, 100, 200, 500]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="resetAndLoad"
|
||||
/>
|
||||
</div>
|
||||
<el-table v-else :data="auditItems" v-loading="loading" size="small" class="table-scroll--lg">
|
||||
<el-table-column prop="created_at" :label="t('common.time')" width="168" />
|
||||
<el-table-column prop="action" :label="t('logs.action')" width="100" />
|
||||
<el-table-column prop="resource" :label="t('logs.resource')" width="120" />
|
||||
<el-table-column prop="detail" :label="t('logs.detail')" min-width="200" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div v-if="activeTab === 'audit'" class="card-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="auditPage"
|
||||
v-model:page-size="auditPageSize"
|
||||
:page-sizes="[50, 100, 200, 500]"
|
||||
:total="auditTotal"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="() => { auditPage = 1; load() }"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '@/api/client'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface CountryStat {
|
||||
code: string
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface OutboundMember {
|
||||
runtime_name: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface OutboundGroup {
|
||||
runtime_name: string
|
||||
name: string
|
||||
type: string
|
||||
source: string
|
||||
now: string
|
||||
now_name: string
|
||||
selectable: boolean
|
||||
is_country?: boolean
|
||||
members: OutboundMember[]
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const savingGroup = ref('')
|
||||
const defaultPolicy = ref('DIRECT')
|
||||
const groups = ref<OutboundGroup[]>([])
|
||||
const countries = ref<CountryStat[]>([])
|
||||
|
||||
const countryGroups = computed(() => groups.value.filter(g => g.is_country))
|
||||
const policyGroups = computed(() => groups.value.filter(g => !g.is_country))
|
||||
|
||||
const policyOptions = computed(() => {
|
||||
const opts = [
|
||||
{ value: 'DIRECT', label: t('rules.targetDirect') },
|
||||
{ value: 'REJECT', label: t('rules.targetReject') },
|
||||
]
|
||||
for (const c of countries.value) {
|
||||
opts.push({ value: `country:${c.code}`, label: t('outbound.countryPoolName', { name: c.name }) })
|
||||
}
|
||||
for (const g of policyGroups.value) {
|
||||
opts.push({ value: g.runtime_name, label: t('outbound.policyGroupName', { name: g.name, type: groupTypeLabel(g.type) }) })
|
||||
}
|
||||
return opts
|
||||
})
|
||||
|
||||
function groupTypeLabel(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
Selector: t('outbound.typeSelector'),
|
||||
URLTest: t('outbound.typeURLTest'),
|
||||
Fallback: t('outbound.typeFallback'),
|
||||
LoadBalance: t('outbound.typeLoadBalance'),
|
||||
Relay: t('outbound.typeRelay'),
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [outbound, countryRes] = await Promise.all([
|
||||
api.get('/outbound'),
|
||||
api.get('/countries'),
|
||||
])
|
||||
const data = outbound.data
|
||||
defaultPolicy.value = data.default_policy || 'DIRECT'
|
||||
groups.value = data.groups || []
|
||||
countries.value = countryRes.data.items || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDefaultPolicy() {
|
||||
await api.put('/outbound/default-policy', { policy: defaultPolicy.value })
|
||||
ElMessage.success(t('outbound.saved'))
|
||||
load()
|
||||
}
|
||||
|
||||
async function selectGroup(group: OutboundGroup, proxy: string) {
|
||||
savingGroup.value = group.runtime_name
|
||||
try {
|
||||
await api.put(`/outbound/groups/${encodeURIComponent(group.runtime_name)}`, { proxy })
|
||||
ElMessage.success(t('outbound.groupSwitched'))
|
||||
await load()
|
||||
} finally {
|
||||
savingGroup.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader :title="t('outbound.title')" :description="t('outbound.desc')">
|
||||
<template #actions>
|
||||
<el-button @click="load">{{ t('common.refresh') }}</el-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<AppCard :title="t('outbound.defaultTitle')" padded>
|
||||
<p class="form-hint form-hint--flush u-mb-4">{{ t('outbound.defaultHint') }}</p>
|
||||
<div class="inline-actions">
|
||||
<el-select v-model="defaultPolicy" filterable class="toolbar-select--wide">
|
||||
<el-option
|
||||
v-for="opt in policyOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="saveDefaultPolicy">{{ t('outbound.save') }}</el-button>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard :title="t('outbound.countryPools')">
|
||||
<div class="card-toolbar card-toolbar--desc">
|
||||
<p class="form-hint form-hint--flush">{{ t('outbound.countryPoolsHint') }}</p>
|
||||
</div>
|
||||
<el-table :data="countryGroups" v-loading="loading" size="small" :empty-text="t('outbound.emptyCountry')">
|
||||
<el-table-column prop="name" :label="t('outbound.countryPool')" min-width="140" />
|
||||
<el-table-column :label="t('outbound.currentNode')" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="geo-status">
|
||||
<span>{{ row.now_name || row.now || t('common.none') }}</span>
|
||||
<span class="form-hint form-hint--flush">{{ t('outbound.auto') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('outbound.memberCount')" width="90">
|
||||
<template #default="{ row }">{{ row.members.length }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" :label="t('common.source')" width="90" />
|
||||
</el-table>
|
||||
</AppCard>
|
||||
|
||||
<AppCard :title="t('outbound.policyGroups')">
|
||||
<div class="card-toolbar card-toolbar--desc">
|
||||
<p class="form-hint form-hint--flush">{{ t('outbound.policyGroupsHint') }}</p>
|
||||
</div>
|
||||
<el-table :data="policyGroups" v-loading="loading" size="small" :empty-text="t('outbound.emptyPolicy')">
|
||||
<el-table-column prop="name" :label="t('outbound.policyGroup')" min-width="140" />
|
||||
<el-table-column :label="t('common.type')" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.selectable ? 'primary' : 'success'">
|
||||
{{ groupTypeLabel(row.type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('outbound.currentNode')" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<div class="geo-status">
|
||||
<span>{{ row.now_name || row.now || t('common.none') }}</span>
|
||||
<span v-if="!row.selectable" class="form-hint form-hint--flush">{{ t('outbound.auto') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('outbound.switchNode')" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-if="row.selectable"
|
||||
:model-value="row.now"
|
||||
filterable
|
||||
:loading="savingGroup === row.runtime_name"
|
||||
class="table-cell-select"
|
||||
@change="(v: string) => selectGroup(row, v)"
|
||||
>
|
||||
<el-option
|
||||
v-for="m in row.members"
|
||||
:key="m.runtime_name"
|
||||
:label="m.name"
|
||||
:value="m.runtime_name"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-else-if="row.type === 'Fallback'" class="form-hint">{{ t('outbound.fallbackHint') }}</span>
|
||||
<span v-else class="form-hint">{{ t('outbound.skipDirect') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" :label="t('common.source')" width="90" />
|
||||
</el-table>
|
||||
</AppCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,392 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '@/api/client'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface ProxyNode {
|
||||
id: number
|
||||
name: string
|
||||
runtime_name: string
|
||||
type: string
|
||||
source: string
|
||||
enabled: boolean
|
||||
country: string
|
||||
latency_ms: number
|
||||
alive: boolean
|
||||
}
|
||||
|
||||
interface CountryStat {
|
||||
code: string
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const items = ref<ProxyNode[]>([])
|
||||
const countries = ref<CountryStat[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('all')
|
||||
const countryFilter = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const testing = ref(false)
|
||||
const testProgress = ref('')
|
||||
const testCancel = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = ref({ name: '', type: 'socks5', server: '', port: 1080, username: '', password: '' })
|
||||
|
||||
const importVisible = ref(false)
|
||||
const importMode = ref<'append' | 'replace'>('append')
|
||||
const importText = ref('')
|
||||
const importing = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
|
||||
const testable = computed(() => items.value.filter(i => i.enabled))
|
||||
|
||||
function sourceParam() {
|
||||
if (activeTab.value === 'manual') return 'manual'
|
||||
if (activeTab.value === 'subscription') return 'subscription'
|
||||
return ''
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [proxies, countryRes] = await Promise.all([
|
||||
api.get('/proxies', {
|
||||
params: {
|
||||
source: sourceParam() || undefined,
|
||||
country: countryFilter.value || undefined,
|
||||
limit: pageSize.value,
|
||||
offset: (page.value - 1) * pageSize.value,
|
||||
},
|
||||
}),
|
||||
api.get('/countries'),
|
||||
])
|
||||
items.value = proxies.data.items || []
|
||||
total.value = proxies.data.total || 0
|
||||
countries.value = countryRes.data.items || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function countryLabel(code: string) {
|
||||
if (!code) return t('common.unknown')
|
||||
const hit = countries.value.find(c => c.code === code)
|
||||
return hit ? hit.name : code
|
||||
}
|
||||
|
||||
function latencyClass(ms: number) {
|
||||
if (ms < 0) return 'latency-dead'
|
||||
if (ms < 200) return 'latency-good'
|
||||
if (ms < 500) return 'latency-warn'
|
||||
return 'latency-bad'
|
||||
}
|
||||
|
||||
async function rebuildCountries() {
|
||||
const { data } = await api.post('/proxies/rebuild-countries')
|
||||
ElMessage.success(t('proxies.countriesRebuilt', { n: data.updated }))
|
||||
load()
|
||||
}
|
||||
|
||||
async function testOne(row: ProxyNode) {
|
||||
const { data } = await api.post(`/proxies/${row.id}/test`, {}, { timeout: 12000 })
|
||||
row.latency_ms = data.latency_ms
|
||||
row.alive = data.alive
|
||||
if (data.error) {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelTest() {
|
||||
testCancel.value = true
|
||||
}
|
||||
|
||||
async function testAll() {
|
||||
const list = testable.value
|
||||
if (!list.length) {
|
||||
ElMessage.info(t('proxies.noTestable'))
|
||||
return
|
||||
}
|
||||
testing.value = true
|
||||
testCancel.value = false
|
||||
let failed = 0
|
||||
try {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
if (testCancel.value) {
|
||||
ElMessage.warning(t('proxies.testCancelled'))
|
||||
break
|
||||
}
|
||||
const row = list[i]
|
||||
testProgress.value = `${i + 1}/${list.length}`
|
||||
try {
|
||||
await testOne(row)
|
||||
} catch {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
if (!testCancel.value) {
|
||||
ElMessage.success(failed ? t('proxies.testDoneFailed', { n: failed }) : t('proxies.testDone'))
|
||||
}
|
||||
} finally {
|
||||
testing.value = false
|
||||
testProgress.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.value = { name: '', type: 'socks5', server: '', port: 1080, username: '', password: '' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const raw = {
|
||||
type: form.value.type,
|
||||
server: form.value.server,
|
||||
port: form.value.port,
|
||||
...(form.value.username ? { username: form.value.username, password: form.value.password } : {}),
|
||||
}
|
||||
await api.post('/proxies', {
|
||||
name: form.value.name,
|
||||
type: form.value.type,
|
||||
raw_config: JSON.stringify(raw),
|
||||
})
|
||||
ElMessage.success(t('proxies.nodeAdded'))
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBlob(data: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function exportProxies(format: 'json' | 'links') {
|
||||
exportLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/proxies/export', {
|
||||
params: { format: format === 'links' ? 'links' : 'json' },
|
||||
responseType: 'blob',
|
||||
})
|
||||
downloadBlob(data, `prism-proxies.${format === 'links' ? 'txt' : 'json'}`)
|
||||
ElMessage.success(t('proxies.exported'))
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openImport() {
|
||||
importMode.value = 'append'
|
||||
importText.value = ''
|
||||
importVisible.value = true
|
||||
}
|
||||
|
||||
function onImportFileChange(uploadFile: { raw?: File }) {
|
||||
const raw = uploadFile.raw
|
||||
if (!raw) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
importText.value = String(reader.result || '')
|
||||
}
|
||||
reader.readAsText(raw)
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
const text = importText.value.trim()
|
||||
if (!text) {
|
||||
ElMessage.warning(t('proxies.pasteRequired'))
|
||||
return
|
||||
}
|
||||
if (importMode.value === 'replace') {
|
||||
await ElMessageBox.confirm(t('proxies.importConfirmReplace'), t('proxies.importConfirmTitle'), {
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
importing.value = true
|
||||
try {
|
||||
const { data } = await api.post('/proxies/import', {
|
||||
mode: importMode.value,
|
||||
text,
|
||||
})
|
||||
const errs: string[] = data.errors || []
|
||||
if (errs.length) {
|
||||
ElMessage.warning(t('proxies.importedPartial', { n: data.imported, s: data.skipped }))
|
||||
} else {
|
||||
ElMessage.success(t('proxies.imported', { n: data.imported }))
|
||||
}
|
||||
importVisible.value = false
|
||||
load()
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string; errors?: string[] } } }
|
||||
const detail = err.response?.data?.errors?.join(';') || err.response?.data?.error
|
||||
ElMessage.error(detail || t('proxies.importFailed'))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader :title="t('proxies.title')" :description="t('proxies.desc')">
|
||||
<template #actions>
|
||||
<el-button @click="rebuildCountries">{{ t('proxies.rebuildCountries') }}</el-button>
|
||||
<el-dropdown trigger="click" @command="(cmd: 'json' | 'links') => exportProxies(cmd)">
|
||||
<el-button :loading="exportLoading">{{ t('common.export') }}</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="json">{{ t('proxies.exportJson') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="links">{{ t('proxies.exportLinks') }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button @click="openImport">{{ t('common.import') }}</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ t('proxies.addNode') }}</el-button>
|
||||
<el-button v-if="testing" @click="cancelTest">{{ t('proxies.cancelTest') }}</el-button>
|
||||
<el-button :loading="testing" @click="testAll">
|
||||
{{ testing && testProgress ? t('proxies.testingProgress', { progress: testProgress }) : t('proxies.testAll') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<AppCard>
|
||||
<div class="tabs-bar">
|
||||
<el-tabs v-model="activeTab" @tab-change="onFilterChange">
|
||||
<el-tab-pane :label="t('proxies.tabAll')" name="all" />
|
||||
<el-tab-pane :label="t('common.subscription')" name="subscription" />
|
||||
<el-tab-pane :label="t('common.manual')" name="manual" />
|
||||
</el-tabs>
|
||||
</div>
|
||||
<div class="card-toolbar">
|
||||
<el-select v-model="countryFilter" clearable :placeholder="t('proxies.filterCountry')" class="toolbar-select--country" @change="onFilterChange">
|
||||
<el-option :label="t('proxies.allCountries')" value="" />
|
||||
<el-option
|
||||
v-for="c in countries"
|
||||
:key="c.code"
|
||||
:label="`${c.name} (${c.count})`"
|
||||
:value="c.code"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-if="testing" class="form-hint form-hint--flush">{{ t('proxies.testingHint') }}</span>
|
||||
</div>
|
||||
<el-table :data="items" v-loading="loading" size="small">
|
||||
<el-table-column prop="name" :label="t('common.name')" min-width="140" />
|
||||
<el-table-column :label="t('proxies.country')" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.country" size="small" type="info">{{ countryLabel(row.country) }}</el-tag>
|
||||
<span v-else class="form-hint">{{ t('common.none') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" :label="t('common.type')" width="90" />
|
||||
<el-table-column prop="source" :label="t('common.source')" width="90" />
|
||||
<el-table-column :label="t('proxies.latency')" width="100">
|
||||
<template #default="{ row }">
|
||||
<span :class="latencyClass(row.latency_ms)">
|
||||
{{ row.latency_ms >= 0 ? `${row.latency_ms} ms` : t('proxies.notTested') }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.status')" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.alive ? 'success' : 'info'" size="small">
|
||||
{{ row.alive ? t('proxies.alive') : t('common.unknown') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link :disabled="testing" @click="testOne(row)">{{ t('proxies.testOne') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[50, 100, 200, 500]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="onFilterChange"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<el-dialog v-model="importVisible" :title="t('proxies.importTitle')" width="560px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item :label="t('proxies.importMode')">
|
||||
<el-radio-group v-model="importMode">
|
||||
<el-radio value="append">{{ t('proxies.append') }}</el-radio>
|
||||
<el-radio value="replace">{{ t('proxies.replace') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('proxies.importFile')">
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".txt,.json"
|
||||
:on-change="onImportFileChange"
|
||||
>
|
||||
<el-button>{{ t('proxies.chooseFile') }}</el-button>
|
||||
</el-upload>
|
||||
<p class="form-hint">{{ t('proxies.importFileHint') }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('proxies.importContent')">
|
||||
<el-input
|
||||
v-model="importText"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
:placeholder="t('proxies.importPlaceholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="importVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="submitImport">{{ t('common.import') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="t('proxies.addDialogTitle')" width="520px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item :label="t('common.name')"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item :label="t('common.type')">
|
||||
<el-select v-model="form.type" class="w-full">
|
||||
<el-option label="SOCKS5" value="socks5" />
|
||||
<el-option label="HTTP" value="http" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('proxies.server')"><el-input v-model="form.server" /></el-form-item>
|
||||
<el-form-item :label="t('proxies.port')"><el-input-number v-model="form.port" :min="1" :max="65535" /></el-form-item>
|
||||
<el-form-item :label="t('proxies.username')"><el-input v-model="form.username" /></el-form-item>
|
||||
<el-form-item :label="t('proxies.password')"><el-input v-model="form.password" type="password" show-password /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">{{ t('common.confirm') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,430 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '@/api/client'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Rule {
|
||||
id: number
|
||||
priority: number
|
||||
rule_type: string
|
||||
payload: string
|
||||
target: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface CountryStat {
|
||||
code: string
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface MatchResult {
|
||||
matched: boolean
|
||||
host: string
|
||||
port?: string
|
||||
rule_line: string
|
||||
rule_type: string
|
||||
payload: string
|
||||
target: string
|
||||
target_label: string
|
||||
source: string
|
||||
priority: number
|
||||
note?: string
|
||||
}
|
||||
|
||||
const manualRules = ref<Rule[]>([])
|
||||
const mergedRules = ref<{ rule: string; source: string; priority: number }[]>([])
|
||||
const countries = ref<CountryStat[]>([])
|
||||
const loadingManual = ref(false)
|
||||
const loadingMerged = ref(false)
|
||||
const mergedTotal = ref(0)
|
||||
const defaultPolicy = ref('DIRECT')
|
||||
const mergedPage = ref(1)
|
||||
const mergedPageSize = 50
|
||||
const dialogVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const targetMode = ref<'policy' | 'country'>('country')
|
||||
const form = ref({ rule_type: 'DOMAIN-SUFFIX', payload: '', target: 'country:HK', priority: 100 })
|
||||
|
||||
const testURL = ref('')
|
||||
const testing = ref(false)
|
||||
const testResult = ref<MatchResult | null>(null)
|
||||
|
||||
const importVisible = ref(false)
|
||||
const importMode = ref<'append' | 'replace'>('append')
|
||||
const importText = ref('')
|
||||
const importing = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
|
||||
const countryTargets = computed(() =>
|
||||
countries.value.map(c => ({
|
||||
value: `country:${c.code}`,
|
||||
label: t('rules.countryFastest', { name: c.name }),
|
||||
})),
|
||||
)
|
||||
|
||||
const policyTargets = computed(() => [
|
||||
{ value: 'DIRECT', label: t('rules.targetDirect') },
|
||||
{ value: 'REJECT', label: t('rules.targetReject') },
|
||||
{ value: 'PROXY', label: t('rules.targetProxy') },
|
||||
{ value: 'GLOBAL', label: t('rules.targetGlobal') },
|
||||
])
|
||||
|
||||
const currentTargetOptions = computed(() =>
|
||||
targetMode.value === 'country' ? countryTargets.value : policyTargets.value,
|
||||
)
|
||||
|
||||
watch(targetMode, (mode) => {
|
||||
if (mode === 'country') {
|
||||
const first = countries.value[0]
|
||||
form.value.target = first ? `country:${first.code}` : 'country:HK'
|
||||
} else {
|
||||
form.value.target = 'PROXY'
|
||||
}
|
||||
})
|
||||
|
||||
async function loadManual() {
|
||||
loadingManual.value = true
|
||||
try {
|
||||
const { data } = await api.get('/rules?source=manual')
|
||||
manualRules.value = data.items || []
|
||||
} finally {
|
||||
loadingManual.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCountries() {
|
||||
const { data } = await api.get('/countries')
|
||||
countries.value = data.items || []
|
||||
}
|
||||
|
||||
async function loadMerged() {
|
||||
loadingMerged.value = true
|
||||
try {
|
||||
const { data } = await api.get('/rules/merged', {
|
||||
params: {
|
||||
limit: mergedPageSize,
|
||||
offset: (mergedPage.value - 1) * mergedPageSize,
|
||||
},
|
||||
})
|
||||
mergedRules.value = data.items || []
|
||||
mergedTotal.value = data.total || 0
|
||||
defaultPolicy.value = data.default_policy || 'DIRECT'
|
||||
} finally {
|
||||
loadingMerged.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onMergedPageChange(page: number) {
|
||||
mergedPage.value = page
|
||||
loadMerged()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
await loadManual()
|
||||
void loadCountries()
|
||||
void loadMerged()
|
||||
}
|
||||
|
||||
function targetLabel(target: string) {
|
||||
if (target.startsWith('country:')) {
|
||||
const code = target.slice(8)
|
||||
const hit = countries.value.find(c => c.code === code)
|
||||
return hit ? t('rules.countryFastest', { name: hit.name }) : target
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
targetMode.value = countries.value.length ? 'country' : 'policy'
|
||||
form.value = {
|
||||
rule_type: 'DOMAIN-SUFFIX',
|
||||
payload: '',
|
||||
target: countries.value[0] ? `country:${countries.value[0].code}` : 'PROXY',
|
||||
priority: 100,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/rules', form.value)
|
||||
ElMessage.success(t('rules.ruleAdded'))
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: Rule) {
|
||||
await ElMessageBox.confirm(t('rules.confirmDelete'), t('common.confirm'))
|
||||
await api.delete(`/rules/${row.id}`)
|
||||
load()
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
const url = testURL.value.trim()
|
||||
if (!url) {
|
||||
ElMessage.warning(t('rules.urlRequired'))
|
||||
return
|
||||
}
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const { data } = await api.post('/rules/test', { url })
|
||||
testResult.value = data
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBlob(data: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function exportRules(format: 'json' | 'text') {
|
||||
exportLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/rules/export', {
|
||||
params: { format: format === 'text' ? 'text' : 'json' },
|
||||
responseType: 'blob',
|
||||
})
|
||||
downloadBlob(data, `prism-rules.${format === 'text' ? 'txt' : 'json'}`)
|
||||
ElMessage.success(t('rules.exported'))
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openImport() {
|
||||
importMode.value = 'append'
|
||||
importText.value = ''
|
||||
importVisible.value = true
|
||||
}
|
||||
|
||||
function onImportFileChange(uploadFile: { raw?: File }) {
|
||||
const raw = uploadFile.raw
|
||||
if (!raw) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
importText.value = String(reader.result || '')
|
||||
}
|
||||
reader.readAsText(raw)
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
const text = importText.value.trim()
|
||||
if (!text) {
|
||||
ElMessage.warning(t('rules.pasteRequired'))
|
||||
return
|
||||
}
|
||||
if (importMode.value === 'replace') {
|
||||
await ElMessageBox.confirm(t('rules.importConfirmReplace'), t('common.confirm'), {
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
importing.value = true
|
||||
try {
|
||||
const { data } = await api.post('/rules/import', {
|
||||
mode: importMode.value,
|
||||
text,
|
||||
})
|
||||
const errs: string[] = data.errors || []
|
||||
if (errs.length) {
|
||||
ElMessage.warning(t('rules.importedPartial', { n: data.imported, s: data.skipped }))
|
||||
} else {
|
||||
ElMessage.success(t('rules.imported', { n: data.imported }))
|
||||
}
|
||||
importVisible.value = false
|
||||
load()
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string; errors?: string[] } } }
|
||||
const detail = err.response?.data?.errors?.join(';') || err.response?.data?.error
|
||||
ElMessage.error(detail || t('rules.importFailed'))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader :title="t('rules.title')" :description="t('rules.desc')">
|
||||
<template #actions>
|
||||
<el-button @click="load">{{ t('common.refresh') }}</el-button>
|
||||
<el-dropdown trigger="click" @command="(cmd: 'json' | 'text') => exportRules(cmd)">
|
||||
<el-button :loading="exportLoading">
|
||||
{{ t('common.export') }}
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="json">{{ t('rules.exportJson') }}</el-dropdown-item>
|
||||
<el-dropdown-item command="text">{{ t('rules.exportText') }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-button @click="openImport">{{ t('common.import') }}</el-button>
|
||||
<el-button type="primary" @click="openCreate">{{ t('rules.addDialogTitle') }}</el-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<AppCard :title="t('rules.testCard')">
|
||||
<div class="card-toolbar">
|
||||
<el-input
|
||||
v-model="testURL"
|
||||
:placeholder="t('rules.testPlaceholder')"
|
||||
clearable
|
||||
class="toolbar-input--grow"
|
||||
@keyup.enter="runTest"
|
||||
/>
|
||||
<el-button type="primary" :loading="testing" @click="runTest">{{ t('rules.test') }}</el-button>
|
||||
</div>
|
||||
<div v-if="testResult" class="card-inset-block test-result">
|
||||
<p><span class="label">{{ t('rules.parsedHost') }}</span>{{ testResult.host }}<span v-if="testResult.port">:{{ testResult.port }}</span></p>
|
||||
<p><span class="label">{{ t('rules.matchedRule') }}</span><code class="inline-code">{{ testResult.rule_line }}</code></p>
|
||||
<p><span class="label">{{ t('rules.outboundTarget') }}</span>{{ testResult.target_label || testResult.target }}</p>
|
||||
<p><span class="label">{{ t('common.source') }}</span>{{ testResult.source }} · {{ t('rules.priority') }} {{ testResult.priority }}</p>
|
||||
<p v-if="testResult.note" class="form-hint form-hint--flush">{{ testResult.note }}</p>
|
||||
</div>
|
||||
<p v-else class="form-hint form-hint--flush card-footer-hint">{{ t('rules.testHint') }}</p>
|
||||
</AppCard>
|
||||
|
||||
<div class="content-grid content-grid--2">
|
||||
<AppCard :title="t('rules.manualRules')">
|
||||
<el-table :data="manualRules" v-loading="loadingManual" size="small">
|
||||
<el-table-column prop="priority" :label="t('rules.priority')" width="80" />
|
||||
<el-table-column prop="rule_type" :label="t('common.type')" width="120" />
|
||||
<el-table-column prop="payload" :label="t('rules.match')" min-width="120" />
|
||||
<el-table-column :label="t('rules.target')" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ targetLabel(row.target) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" link @click="remove(row)">{{ t('common.delete') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</AppCard>
|
||||
<AppCard :title="t('rules.mergedPreview')">
|
||||
<div class="card-toolbar card-toolbar--desc">
|
||||
<p class="form-hint form-hint--flush">{{ t('rules.mergedSummary', { total: mergedTotal, policy: targetLabel(defaultPolicy) }) }}</p>
|
||||
</div>
|
||||
<el-table :data="mergedRules" v-loading="loadingMerged" size="small" class="table-scroll--md">
|
||||
<el-table-column :label="t('rules.seq')" width="70">
|
||||
<template #default="{ $index }">
|
||||
{{ (mergedPage - 1) * mergedPageSize + $index + 1 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="priority" :label="t('rules.priority')" width="80" />
|
||||
<el-table-column prop="rule" :label="t('rules.rule')" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="source" :label="t('common.source')" width="90" />
|
||||
</el-table>
|
||||
<div class="card-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="mergedPage"
|
||||
:page-size="mergedPageSize"
|
||||
:total="mergedTotal"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
@current-change="onMergedPageChange"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="importVisible" :title="t('rules.importTitle')" width="560px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item :label="t('rules.importMode')">
|
||||
<el-radio-group v-model="importMode">
|
||||
<el-radio value="append">{{ t('rules.append') }}</el-radio>
|
||||
<el-radio value="replace">{{ t('rules.replace') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('rules.importFile')">
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".txt,.json,.yaml,.yml"
|
||||
:on-change="onImportFileChange"
|
||||
>
|
||||
<el-button>{{ t('rules.chooseFile') }}</el-button>
|
||||
</el-upload>
|
||||
<p class="form-hint">{{ t('rules.importFileHint') }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('rules.importContent')">
|
||||
<el-input
|
||||
v-model="importText"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
:placeholder="t('rules.importPlaceholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="importVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="submitImport">{{ t('common.import') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="t('rules.addDialogTitle')" width="520px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item :label="t('common.type')">
|
||||
<el-select v-model="form.rule_type" class="w-full">
|
||||
<el-option label="DOMAIN" value="DOMAIN" />
|
||||
<el-option label="DOMAIN-SUFFIX" value="DOMAIN-SUFFIX" />
|
||||
<el-option label="DOMAIN-KEYWORD" value="DOMAIN-KEYWORD" />
|
||||
<el-option label="IP-CIDR" value="IP-CIDR" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('rules.matchValue')">
|
||||
<el-input v-model="form.payload" :placeholder="t('rules.matchPlaceholder')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('rules.outboundType')">
|
||||
<el-radio-group v-model="targetMode">
|
||||
<el-radio value="country">{{ t('rules.byCountry') }}</el-radio>
|
||||
<el-radio value="policy">{{ t('rules.byPolicy') }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('rules.outboundTargetLabel')">
|
||||
<el-select
|
||||
:key="targetMode"
|
||||
v-model="form.target"
|
||||
filterable
|
||||
class="w-full"
|
||||
:placeholder="targetMode === 'country' ? t('rules.selectCountryPool') : t('rules.selectPolicy')"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in currentTargetOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<p class="form-hint">{{ t('rules.countryPoolHint') }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('rules.priority')">
|
||||
<el-input-number v-model="form.priority" :min="1" :max="9999" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">{{ t('common.confirm') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,383 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '@/api/client'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
import { useLocaleStore } from '@/stores/locale'
|
||||
import { isLocaleCode } from '@/i18n/types'
|
||||
|
||||
interface GeoFile {
|
||||
name: string
|
||||
exists: boolean
|
||||
size: number
|
||||
url: string
|
||||
}
|
||||
|
||||
interface APIKeyItem {
|
||||
id: number
|
||||
name: string
|
||||
key_prefix: string
|
||||
enabled: boolean
|
||||
last_used_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const localeStore = useLocaleStore()
|
||||
|
||||
const form = ref<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const geoLoading = ref(false)
|
||||
const geoUpdating = ref(false)
|
||||
const geoReady = ref(false)
|
||||
const geoFiles = ref<GeoFile[]>([])
|
||||
const apiKeys = ref<APIKeyItem[]>([])
|
||||
const apiKeysLoading = ref(false)
|
||||
const creatingKey = ref(false)
|
||||
const keyDialogVisible = ref(false)
|
||||
const newKeyName = ref('')
|
||||
const createdKey = ref('')
|
||||
|
||||
const localeOptions = computed(() =>
|
||||
localeStore.options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: t(opt.labelKey),
|
||||
})),
|
||||
)
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) return t('apiKey.neverUsed')
|
||||
return value.replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (!n) return t('common.none')
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
async function loadAPIKeys() {
|
||||
apiKeysLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api-keys')
|
||||
apiKeys.value = data.items || []
|
||||
} finally {
|
||||
apiKeysLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGeo() {
|
||||
geoLoading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/system/geo')
|
||||
geoReady.value = !!data.ready
|
||||
geoFiles.value = data.files || []
|
||||
} finally {
|
||||
geoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/settings')
|
||||
form.value = data
|
||||
localeStore.applyFromSettings(data.ui_locale)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
void loadGeo()
|
||||
void loadAPIKeys()
|
||||
}
|
||||
|
||||
function onLocaleChange(value: string) {
|
||||
if (isLocaleCode(value)) {
|
||||
localeStore.setLocale(value)
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put('/settings', form.value)
|
||||
if (form.value.ui_locale && isLocaleCode(form.value.ui_locale)) {
|
||||
localeStore.setLocale(form.value.ui_locale)
|
||||
}
|
||||
ElMessage.success(t('settings.saved'))
|
||||
await loadGeo()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadEngine() {
|
||||
await api.post('/system/reload')
|
||||
ElMessage.success(t('settings.engineReloaded'))
|
||||
}
|
||||
|
||||
async function updateGeo(force = false) {
|
||||
geoUpdating.value = true
|
||||
try {
|
||||
const { data } = await api.post('/system/geo/update', { force })
|
||||
geoReady.value = !!data.ready
|
||||
geoFiles.value = data.files || []
|
||||
const updated: string[] = data.updated || []
|
||||
if (updated.length) {
|
||||
ElMessage.success(t('geo.updated', { files: updated.join('、') }))
|
||||
} else {
|
||||
ElMessage.success(t('geo.upToDate'))
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } }
|
||||
ElMessage.error(err.response?.data?.error || t('geo.updateFailed'))
|
||||
} finally {
|
||||
geoUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateKey() {
|
||||
newKeyName.value = ''
|
||||
createdKey.value = ''
|
||||
keyDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function createAPIKey() {
|
||||
creatingKey.value = true
|
||||
try {
|
||||
const { data } = await api.post('/api-keys', {
|
||||
name: newKeyName.value.trim() || 'default',
|
||||
})
|
||||
createdKey.value = data.key
|
||||
ElMessage.success(t('apiKey.created'))
|
||||
await loadAPIKeys()
|
||||
} finally {
|
||||
creatingKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCreatedKey() {
|
||||
if (!createdKey.value) return
|
||||
await navigator.clipboard.writeText(createdKey.value)
|
||||
ElMessage.success(t('apiKey.copied'))
|
||||
}
|
||||
|
||||
async function revokeAPIKey(row: APIKeyItem) {
|
||||
await ElMessageBox.confirm(
|
||||
t('apiKey.confirmRevoke', { name: row.name, prefix: row.key_prefix }),
|
||||
t('common.confirm'),
|
||||
{ type: 'warning' },
|
||||
)
|
||||
await api.delete(`/api-keys/${row.id}`)
|
||||
ElMessage.success(t('apiKey.revoked'))
|
||||
loadAPIKeys()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader :title="t('settings.title')" :description="t('settings.desc')">
|
||||
<template #actions>
|
||||
<el-button @click="reloadEngine">{{ t('settings.reloadEngine') }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">{{ t('common.save') }}</el-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<AppCard :title="t('settings.inboundApi')" padded v-loading="loading">
|
||||
<el-form label-width="120px">
|
||||
<div class="section-title">{{ t('settings.appearance') }}</div>
|
||||
<el-form-item :label="t('settings.uiLocale')">
|
||||
<el-select v-model="form.ui_locale" class="w-full" @change="onLocaleChange">
|
||||
<el-option
|
||||
v-for="opt in localeOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<div class="section-title">{{ t('settings.proxyInbound') }}</div>
|
||||
<el-form-item :label="t('settings.httpPort')">
|
||||
<el-input v-model="form.http_port" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('settings.socksPort')">
|
||||
<el-input v-model="form.socks_port" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('settings.proxyUser')">
|
||||
<el-input v-model="form.proxy_auth_user" :placeholder="t('settings.proxyUserPlaceholder')" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('settings.proxyPass')">
|
||||
<el-input
|
||||
v-model="form.proxy_auth_pass"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="t('settings.proxyPassPlaceholder')"
|
||||
/>
|
||||
<p class="form-hint">{{ t('settings.proxyAuthHint') }}</p>
|
||||
</el-form-item>
|
||||
|
||||
<div class="section-title">{{ t('settings.adminApi') }}</div>
|
||||
<el-form-item :label="t('settings.apiPort')">
|
||||
<el-input v-model="form.api_port" />
|
||||
<p class="form-hint">{{ t('settings.apiPortHint') }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('settings.mihomoApi')">
|
||||
<el-input v-model="form.mihomo_api_port" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('settings.apiSecret')">
|
||||
<el-input v-model="form.api_secret" :placeholder="t('settings.apiSecretPlaceholder')" />
|
||||
</el-form-item>
|
||||
|
||||
<div class="section-title">{{ t('settings.scheduler') }}</div>
|
||||
<el-form-item :label="t('settings.healthInterval')">
|
||||
<el-input v-model="form.health_interval_sec" />
|
||||
<p class="form-hint">{{ t('settings.seconds') }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('settings.logLevel')">
|
||||
<el-select v-model="form.log_level" class="w-full">
|
||||
<el-option label="info" value="info" />
|
||||
<el-option label="warning" value="warning" />
|
||||
<el-option label="error" value="error" />
|
||||
<el-option label="debug" value="debug" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<div class="section-title">{{ t('settings.security') }}</div>
|
||||
<el-form-item :label="t('settings.authEnabled')">
|
||||
<el-switch v-model="form.auth_enabled" active-value="true" inactive-value="false" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('settings.loginMaxAttempts')">
|
||||
<el-input v-model="form.login_max_attempts" />
|
||||
<p class="form-hint">{{ t('settings.loginMaxAttemptsHint') }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('settings.loginLockMinutes')">
|
||||
<el-input v-model="form.login_lock_minutes" />
|
||||
<p class="form-hint">{{ t('settings.loginLockMinutesHint') }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('settings.newPassword')">
|
||||
<el-input v-model="form.admin_password" type="password" show-password :placeholder="t('settings.newPasswordPlaceholder')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</AppCard>
|
||||
|
||||
<AppCard v-loading="apiKeysLoading">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<h3 class="app-card__title">{{ t('settings.apiKeys') }}</h3>
|
||||
<el-button type="primary" @click="openCreateKey">{{ t('settings.applyKey') }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="card-toolbar card-toolbar--desc">
|
||||
<p class="form-hint form-hint--flush">
|
||||
{{ t('settings.apiKeysHint') }}
|
||||
<code class="inline-code">Authorization: Bearer <key></code>
|
||||
<code class="inline-code">X-API-Key: <key></code>
|
||||
</p>
|
||||
</div>
|
||||
<el-table :data="apiKeys" size="small" :empty-text="t('apiKey.empty')">
|
||||
<el-table-column prop="name" :label="t('common.name')" min-width="120" />
|
||||
<el-table-column prop="key_prefix" :label="t('apiKey.prefix')" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<code class="inline-code">{{ row.key_prefix }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('apiKey.lastUsed')" width="168">
|
||||
<template #default="{ row }">{{ formatTime(row.last_used_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('apiKey.createdAt')" width="168">
|
||||
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('common.actions')" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="danger" link @click="revokeAPIKey(row)">{{ t('common.revoke') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</AppCard>
|
||||
|
||||
<AppCard :title="t('geo.title')" padded v-loading="geoLoading">
|
||||
<el-form label-width="140px">
|
||||
<el-form-item :label="t('geo.autoDownload')">
|
||||
<el-switch v-model="form.geo_auto_download" active-value="true" inactive-value="false" />
|
||||
<p class="form-hint">{{ t('geo.autoDownloadHint') }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('geo.countryMmdb')">
|
||||
<el-input v-model="form.geo_country_mmdb_url" clearable />
|
||||
<p class="form-hint">{{ t('geo.countryMmdbHint') }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('geo.geoip')">
|
||||
<el-input v-model="form.geo_geoip_url" clearable />
|
||||
<p class="form-hint">{{ t('geo.geoipHint') }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('geo.geosite')">
|
||||
<el-input v-model="form.geo_geosite_url" clearable />
|
||||
<p class="form-hint">{{ t('geo.geositeHint') }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('geo.localStatus')">
|
||||
<div class="geo-status">
|
||||
<el-tag :type="geoReady ? 'success' : 'warning'" size="small">
|
||||
{{ geoReady ? t('geo.ready') : t('geo.notReady') }}
|
||||
</el-tag>
|
||||
<ul v-if="geoFiles.length" class="geo-file-list">
|
||||
<li v-for="f in geoFiles" :key="f.name">
|
||||
<span class="geo-file-name">{{ f.name }}</span>
|
||||
<span :class="f.exists ? 'geo-file-ok' : 'geo-file-miss'">
|
||||
{{ f.exists ? formatBytes(f.size) : t('geo.missing') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label=" ">
|
||||
<div class="inline-actions">
|
||||
<el-button type="primary" :loading="geoUpdating" @click="updateGeo(false)">
|
||||
{{ t('geo.downloadMissing') }}
|
||||
</el-button>
|
||||
<el-button :loading="geoUpdating" @click="updateGeo(true)">
|
||||
{{ t('geo.forceUpdate') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="form-hint">{{ t('geo.updateHint') }}</p>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</AppCard>
|
||||
|
||||
<el-dialog
|
||||
v-model="keyDialogVisible"
|
||||
:title="createdKey ? t('apiKey.dialogSave') : t('apiKey.dialogCreate')"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="!createdKey"
|
||||
:show-close="!!createdKey"
|
||||
>
|
||||
<template v-if="!createdKey">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item :label="t('apiKey.name')">
|
||||
<el-input v-model="newKeyName" :placeholder="t('apiKey.namePlaceholder')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="form-hint">{{ t('apiKey.onceHint') }}</p>
|
||||
<el-input :model-value="createdKey" readonly class="u-mt-4">
|
||||
<template #append>
|
||||
<el-button @click="copyCreatedKey">{{ t('common.copy') }}</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button v-if="createdKey" type="primary" @click="keyDialogVisible = false">{{ t('common.saved') }}</el-button>
|
||||
<template v-else>
|
||||
<el-button @click="keyDialogVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="creatingKey" @click="createAPIKey">{{ t('common.create') }}</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import api from '@/api/client'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Subscription {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
type: string
|
||||
interval_sec: number
|
||||
enabled: boolean
|
||||
last_fetch_at?: string
|
||||
last_error?: string
|
||||
node_count: number
|
||||
}
|
||||
|
||||
const items = ref<Subscription[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const form = ref({ name: '', url: '', type: 'auto', interval_sec: 3600 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/subscriptions')
|
||||
items.value = data.items || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.value = { name: '', url: '', type: 'auto', interval_sec: 3600 }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/subscriptions', form.value)
|
||||
ElMessage.success(t('subscriptions.added'))
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(row: Subscription) {
|
||||
await api.post(`/subscriptions/${row.id}/refresh`)
|
||||
ElMessage.success(t('subscriptions.refreshed'))
|
||||
load()
|
||||
}
|
||||
|
||||
async function remove(row: Subscription) {
|
||||
await ElMessageBox.confirm(t('subscriptions.confirmDelete', { name: row.name }), t('common.confirm'))
|
||||
await api.delete(`/subscriptions/${row.id}`)
|
||||
ElMessage.success(t('subscriptions.deleted'))
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader :title="t('subscriptions.title')" :description="t('subscriptions.desc')">
|
||||
<template #actions>
|
||||
<el-button type="primary" @click="openCreate">{{ t('subscriptions.add') }}</el-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<AppCard>
|
||||
<el-table :data="items" v-loading="loading" size="small">
|
||||
<el-table-column prop="name" :label="t('common.name')" min-width="120" />
|
||||
<el-table-column prop="type" :label="t('common.type')" width="80" />
|
||||
<el-table-column prop="url" :label="t('subscriptions.url')" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="node_count" :label="t('subscriptions.nodeCount')" width="80" />
|
||||
<el-table-column :label="t('common.status')" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.last_error ? 'danger' : 'success'" size="small">
|
||||
{{ row.last_error ? t('subscriptions.error') : t('subscriptions.normal') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_fetch_at" :label="t('subscriptions.lastFetch')" width="168" />
|
||||
<el-table-column :label="t('common.actions')" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link @click="refresh(row)">{{ t('subscriptions.refresh') }}</el-button>
|
||||
<el-button size="small" type="danger" link @click="remove(row)">{{ t('common.delete') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</AppCard>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="t('subscriptions.dialogTitle')" width="520px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item :label="t('common.name')"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item :label="t('subscriptions.url')"><el-input v-model="form.url" /></el-form-item>
|
||||
<el-form-item :label="t('common.type')">
|
||||
<el-select v-model="form.type" class="w-full">
|
||||
<el-option :label="t('subscriptions.typeAuto')" value="auto" />
|
||||
<el-option :label="t('subscriptions.typeClash')" value="clash" />
|
||||
<el-option :label="t('subscriptions.typeLinks')" value="links" />
|
||||
</el-select>
|
||||
<p class="form-hint">{{ t('subscriptions.typeHint') }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('subscriptions.interval')">
|
||||
<el-input-number v-model="form.interval_sec" :min="300" :step="300" />
|
||||
<p class="form-hint">{{ t('subscriptions.intervalHint') }}</p>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">{{ t('common.confirm') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api/client'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface TrafficLog {
|
||||
id: number
|
||||
host: string
|
||||
network: string
|
||||
rule_line: string
|
||||
outbound_node: string
|
||||
outbound_chain: string
|
||||
upload: number
|
||||
download: number
|
||||
success: boolean
|
||||
started_at: string
|
||||
ended_at: string
|
||||
}
|
||||
|
||||
const items = ref<TrafficLog[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const keyword = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
|
||||
const successCount = computed(() => items.value.filter((i) => i.success).length)
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/traffic-logs', {
|
||||
params: {
|
||||
keyword: keyword.value,
|
||||
limit: pageSize.value,
|
||||
offset: (page.value - 1) * pageSize.value,
|
||||
},
|
||||
})
|
||||
items.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<PageHeader :title="t('trafficLogs.title')" :description="t('trafficLogs.desc')">
|
||||
<template #actions>
|
||||
<el-button @click="load">{{ t('common.refresh') }}</el-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<AppCard>
|
||||
<div class="card-toolbar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
:placeholder="t('trafficLogs.searchPlaceholder')"
|
||||
clearable
|
||||
class="toolbar-input--wide"
|
||||
@keyup.enter="() => { page = 1; load() }"
|
||||
/>
|
||||
<el-button type="primary" @click="() => { page = 1; load() }">{{ t('common.search') }}</el-button>
|
||||
<span v-if="items.length" class="form-hint form-hint--flush">
|
||||
{{ t('trafficLogs.pageSuccess', { ok: successCount, total: items.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<el-table :data="items" v-loading="loading" size="small" class="table-scroll--lg">
|
||||
<el-table-column prop="started_at" :label="t('common.time')" width="168" />
|
||||
<el-table-column prop="host" :label="t('trafficLogs.target')" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="rule_line" :label="t('trafficLogs.matchedRule')" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="outbound_node" :label="t('trafficLogs.outboundNode')" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column :label="t('trafficLogs.chain')" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.outbound_chain || t('common.none') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('trafficLogs.traffic')" width="120">
|
||||
<template #default="{ row }">
|
||||
↑{{ formatBytes(row.upload) }} / ↓{{ formatBytes(row.download) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('trafficLogs.result')" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.success ? 'success' : 'danger'" size="small">
|
||||
{{ row.success ? t('common.success') : t('common.failed') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="card-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[50, 100, 200, 500]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@current-change="load"
|
||||
@size-change="() => { page = 1; load() }"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user