This commit is contained in:
113
web/src/App.vue
113
web/src/App.vue
@@ -1,12 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { getServerStatus } from './api'
|
||||
import { ref, onMounted, computed, h } from 'vue'
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { NLayout, NLayoutHeader, NLayoutContent, NMenu, NButton, NSpace, NTag, NIcon, NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui'
|
||||
import { HomeOutline, ExtensionPuzzleOutline, LogOutOutline } from '@vicons/ionicons5'
|
||||
import type { MenuOption } from 'naive-ui'
|
||||
import { getServerStatus, removeToken } from './api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const serverInfo = ref({ bind_addr: '', bind_port: 0 })
|
||||
const clientCount = ref(0)
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
const menuOptions: MenuOption[] = [
|
||||
{
|
||||
label: '客户端',
|
||||
key: '/',
|
||||
icon: () => h(NIcon, null, { default: () => h(HomeOutline) })
|
||||
},
|
||||
{
|
||||
label: '插件',
|
||||
key: '/plugins',
|
||||
icon: () => h(NIcon, null, { default: () => h(ExtensionPuzzleOutline) })
|
||||
}
|
||||
]
|
||||
|
||||
const activeKey = computed(() => {
|
||||
if (route.path.startsWith('/client/')) return '/'
|
||||
return route.path
|
||||
})
|
||||
|
||||
const handleMenuUpdate = (key: string) => {
|
||||
router.push(key)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (isLoginPage.value) return
|
||||
try {
|
||||
const { data } = await getServerStatus()
|
||||
serverInfo.value = data.server
|
||||
@@ -15,41 +45,50 @@ onMounted(async () => {
|
||||
console.error('Failed to get server status', e)
|
||||
}
|
||||
})
|
||||
|
||||
const logout = () => {
|
||||
removeToken()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="header">
|
||||
<h1>GoTunnel 控制台</h1>
|
||||
<div class="server-info">
|
||||
<span>{{ serverInfo.bind_addr }}:{{ serverInfo.bind_port }}</span>
|
||||
<span class="badge">{{ clientCount }} 客户端</span>
|
||||
</div>
|
||||
</header>
|
||||
<main class="main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
<n-config-provider>
|
||||
<n-dialog-provider>
|
||||
<n-message-provider>
|
||||
<n-layout v-if="!isLoginPage" style="min-height: 100vh;">
|
||||
<n-layout-header bordered style="height: 64px; padding: 0 24px; display: flex; align-items: center; justify-content: space-between;">
|
||||
<div style="display: flex; align-items: center; gap: 32px;">
|
||||
<div style="font-size: 20px; font-weight: 600; color: #18a058; cursor: pointer;" @click="router.push('/')">
|
||||
GoTunnel
|
||||
</div>
|
||||
<n-menu
|
||||
mode="horizontal"
|
||||
:options="menuOptions"
|
||||
:value="activeKey"
|
||||
@update:value="handleMenuUpdate"
|
||||
/>
|
||||
</div>
|
||||
<n-space align="center" :size="16">
|
||||
<n-tag type="info" round>
|
||||
{{ serverInfo.bind_addr }}:{{ serverInfo.bind_port }}
|
||||
</n-tag>
|
||||
<n-tag type="success" round>
|
||||
{{ clientCount }} 客户端
|
||||
</n-tag>
|
||||
<n-button quaternary circle @click="logout">
|
||||
<template #icon>
|
||||
<n-icon><LogOutOutline /></n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-layout-header>
|
||||
<n-layout-content content-style="padding: 24px; max-width: 1200px; margin: 0 auto; width: 100%;">
|
||||
<RouterView />
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
<RouterView v-else />
|
||||
</n-message-provider>
|
||||
</n-dialog-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app { min-height: 100vh; background: #f5f7fa; }
|
||||
.header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header h1 { font-size: 20px; color: #2c3e50; }
|
||||
.server-info { display: flex; align-items: center; gap: 12px; color: #666; }
|
||||
.badge {
|
||||
background: #3498db;
|
||||
color: #fff;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.main { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
import axios from 'axios'
|
||||
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus } from '../types'
|
||||
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo } from '../types'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// Token 管理
|
||||
const TOKEN_KEY = 'gotunnel_token'
|
||||
|
||||
export const getToken = () => localStorage.getItem(TOKEN_KEY)
|
||||
export const setToken = (token: string) => localStorage.setItem(TOKEN_KEY, token)
|
||||
export const removeToken = () => localStorage.removeItem(TOKEN_KEY)
|
||||
|
||||
// 请求拦截器:添加 token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// 响应拦截器:处理 401
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
removeToken()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 认证 API
|
||||
export const login = (username: string, password: string) =>
|
||||
api.post<{ token: string }>('/auth/login', { username, password })
|
||||
export const checkAuth = () => api.get('/auth/check')
|
||||
|
||||
export const getServerStatus = () => api.get<ServerStatus>('/status')
|
||||
export const getClients = () => api.get<ClientStatus[]>('/clients')
|
||||
export const getClient = (id: string) => api.get<ClientDetail>(`/client/${id}`)
|
||||
@@ -14,4 +47,15 @@ export const updateClient = (id: string, client: ClientConfig) => api.put(`/clie
|
||||
export const deleteClient = (id: string) => api.delete(`/client/${id}`)
|
||||
export const reloadConfig = () => api.post('/config/reload')
|
||||
|
||||
// 客户端控制
|
||||
export const pushConfigToClient = (id: string) => api.post(`/client/${id}/push`)
|
||||
export const disconnectClient = (id: string) => api.post(`/client/${id}/disconnect`)
|
||||
export const installPluginsToClient = (id: string, plugins: string[]) =>
|
||||
api.post(`/client/${id}/install-plugins`, { plugins })
|
||||
|
||||
// 插件管理
|
||||
export const getPlugins = () => api.get<PluginInfo[]>('/plugins')
|
||||
export const enablePlugin = (name: string) => api.post(`/plugin/${name}/enable`)
|
||||
export const disablePlugin = (name: string) => api.post(`/plugin/${name}/disable`)
|
||||
|
||||
export default api
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import naive from 'naive-ui'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(naive)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { getToken } from '../api'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
@@ -13,7 +20,24 @@ const router = createRouter({
|
||||
name: 'client',
|
||||
component: () => import('../views/ClientView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/plugins',
|
||||
name: 'plugins',
|
||||
component: () => import('../views/PluginsView.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const token = getToken()
|
||||
if (!to.meta.public && !token) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && token) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,79 +1,15 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,20 @@ export interface ProxyRule {
|
||||
local_ip: string
|
||||
local_port: number
|
||||
remote_port: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
// 客户端配置
|
||||
export interface ClientConfig {
|
||||
id: string
|
||||
nickname?: string
|
||||
rules: ProxyRule[]
|
||||
}
|
||||
|
||||
// 客户端状态
|
||||
export interface ClientStatus {
|
||||
id: string
|
||||
nickname?: string
|
||||
online: boolean
|
||||
last_ping?: string
|
||||
rule_count: number
|
||||
@@ -23,6 +26,7 @@ export interface ClientStatus {
|
||||
// 客户端详情
|
||||
export interface ClientDetail {
|
||||
id: string
|
||||
nickname?: string
|
||||
rules: ProxyRule[]
|
||||
online: boolean
|
||||
last_ping?: string
|
||||
@@ -36,3 +40,23 @@ export interface ServerStatus {
|
||||
}
|
||||
client_count: number
|
||||
}
|
||||
|
||||
// 插件类型
|
||||
export const PluginType = {
|
||||
Proxy: 'proxy',
|
||||
App: 'app',
|
||||
Service: 'service',
|
||||
Tool: 'tool'
|
||||
} as const
|
||||
|
||||
export type PluginTypeValue = typeof PluginType[keyof typeof PluginType]
|
||||
|
||||
// 插件信息
|
||||
export interface PluginInfo {
|
||||
name: string
|
||||
version: string
|
||||
type: string
|
||||
description: string
|
||||
source: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
@@ -1,24 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getClient, updateClient, deleteClient } from '../api'
|
||||
import type { ProxyRule } from '../types'
|
||||
import {
|
||||
NCard, NButton, NSpace, NTag, NTable, NEmpty,
|
||||
NFormItem, NInput, NInputNumber, NSelect, NModal, NCheckbox,
|
||||
NIcon, useMessage, useDialog
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
ArrowBackOutline, CreateOutline, TrashOutline,
|
||||
PushOutline, PowerOutline, AddOutline, SaveOutline, CloseOutline,
|
||||
DownloadOutline
|
||||
} from '@vicons/ionicons5'
|
||||
import { getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, getPlugins, installPluginsToClient } from '../api'
|
||||
import type { ProxyRule, PluginInfo } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const clientId = route.params.id as string
|
||||
|
||||
const online = ref(false)
|
||||
const lastPing = ref('')
|
||||
const nickname = ref('')
|
||||
const rules = ref<ProxyRule[]>([])
|
||||
const editing = ref(false)
|
||||
const editNickname = ref('')
|
||||
const editRules = ref<ProxyRule[]>([])
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'UDP', value: 'udp' },
|
||||
{ label: 'HTTP', value: 'http' },
|
||||
{ label: 'HTTPS', value: 'https' },
|
||||
{ label: 'SOCKS5', value: 'socks5' }
|
||||
]
|
||||
|
||||
// 插件安装相关
|
||||
const showInstallModal = ref(false)
|
||||
const availablePlugins = ref<PluginInfo[]>([])
|
||||
const selectedPlugins = ref<string[]>([])
|
||||
|
||||
const loadPlugins = async () => {
|
||||
try {
|
||||
const { data } = await getPlugins()
|
||||
availablePlugins.value = (data || []).filter(p => p.enabled)
|
||||
} catch (e) {
|
||||
console.error('Failed to load plugins', e)
|
||||
}
|
||||
}
|
||||
|
||||
const openInstallModal = async () => {
|
||||
await loadPlugins()
|
||||
selectedPlugins.value = []
|
||||
showInstallModal.value = true
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = { proxy: '协议', app: '应用', service: '服务', tool: '工具' }
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
const loadClient = async () => {
|
||||
try {
|
||||
const { data } = await getClient(clientId)
|
||||
online.value = data.online
|
||||
lastPing.value = data.last_ping || ''
|
||||
nickname.value = data.nickname || ''
|
||||
rules.value = data.rules || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load client', e)
|
||||
@@ -28,7 +76,11 @@ const loadClient = async () => {
|
||||
onMounted(loadClient)
|
||||
|
||||
const startEdit = () => {
|
||||
editRules.value = JSON.parse(JSON.stringify(rules.value))
|
||||
editNickname.value = nickname.value
|
||||
editRules.value = rules.value.map(rule => ({
|
||||
...rule,
|
||||
type: rule.type || 'tcp'
|
||||
}))
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
@@ -38,7 +90,7 @@ const cancelEdit = () => {
|
||||
|
||||
const addRule = () => {
|
||||
editRules.value.push({
|
||||
name: '', local_ip: '127.0.0.1', local_port: 80, remote_port: 8080
|
||||
name: '', local_ip: '127.0.0.1', local_port: 80, remote_port: 8080, type: 'tcp'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,156 +100,228 @@ const removeRule = (index: number) => {
|
||||
|
||||
const saveEdit = async () => {
|
||||
try {
|
||||
await updateClient(clientId, { id: clientId, rules: editRules.value })
|
||||
await updateClient(clientId, { id: clientId, nickname: editNickname.value, rules: editRules.value })
|
||||
editing.value = false
|
||||
message.success('保存成功')
|
||||
loadClient()
|
||||
} catch (e) {
|
||||
alert('保存失败')
|
||||
message.error('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!confirm('确定删除此客户端?')) return
|
||||
const confirmDelete = () => {
|
||||
dialog.warning({
|
||||
title: '确认删除',
|
||||
content: '确定要删除此客户端吗?',
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await deleteClient(clientId)
|
||||
message.success('删除成功')
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const pushConfig = async () => {
|
||||
try {
|
||||
await deleteClient(clientId)
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
alert('删除失败')
|
||||
await pushConfigToClient(clientId)
|
||||
message.success('配置已推送')
|
||||
} catch (e: any) {
|
||||
message.error(e.response?.data || '推送失败')
|
||||
}
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
dialog.warning({
|
||||
title: '确认断开',
|
||||
content: '确定要断开此客户端连接吗?',
|
||||
positiveText: '断开',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await disconnectClient(clientId)
|
||||
online.value = false
|
||||
message.success('已断开连接')
|
||||
} catch (e: any) {
|
||||
message.error(e.response?.data || '断开失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const installPlugins = async () => {
|
||||
if (selectedPlugins.value.length === 0) {
|
||||
message.warning('请选择要安装的插件')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await installPluginsToClient(clientId, selectedPlugins.value)
|
||||
message.success(`已推送 ${selectedPlugins.value.length} 个插件到客户端`)
|
||||
showInstallModal.value = false
|
||||
} catch (e: any) {
|
||||
message.error(e.response?.data || '安装失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="client-view">
|
||||
<div class="header">
|
||||
<button class="btn" @click="router.push('/')">← 返回</button>
|
||||
<h2>{{ clientId }}</h2>
|
||||
<span :class="['status-badge', online ? 'online' : 'offline']">
|
||||
{{ online ? '在线' : '离线' }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 头部信息卡片 -->
|
||||
<n-card style="margin-bottom: 16px;">
|
||||
<n-space justify="space-between" align="center" wrap>
|
||||
<n-space align="center">
|
||||
<n-button quaternary @click="router.push('/')">
|
||||
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
||||
返回
|
||||
</n-button>
|
||||
<h2 style="margin: 0;">{{ nickname || clientId }}</h2>
|
||||
<span v-if="nickname" style="color: #999; font-size: 12px;">{{ clientId }}</span>
|
||||
<n-tag :type="online ? 'success' : 'default'">
|
||||
{{ online ? '在线' : '离线' }}
|
||||
</n-tag>
|
||||
<span v-if="lastPing" style="color: #666; font-size: 14px;">
|
||||
最后心跳: {{ lastPing }}
|
||||
</span>
|
||||
</n-space>
|
||||
<n-space>
|
||||
<template v-if="online">
|
||||
<n-button type="info" @click="pushConfig">
|
||||
<template #icon><n-icon><PushOutline /></n-icon></template>
|
||||
推送配置
|
||||
</n-button>
|
||||
<n-button type="success" @click="openInstallModal">
|
||||
<template #icon><n-icon><DownloadOutline /></n-icon></template>
|
||||
安装插件
|
||||
</n-button>
|
||||
<n-button type="warning" @click="disconnect">
|
||||
<template #icon><n-icon><PowerOutline /></n-icon></template>
|
||||
断开连接
|
||||
</n-button>
|
||||
</template>
|
||||
<template v-if="!editing">
|
||||
<n-button type="primary" @click="startEdit">
|
||||
<template #icon><n-icon><CreateOutline /></n-icon></template>
|
||||
编辑规则
|
||||
</n-button>
|
||||
<n-button type="error" @click="confirmDelete">
|
||||
<template #icon><n-icon><TrashOutline /></n-icon></template>
|
||||
删除
|
||||
</n-button>
|
||||
</template>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<div v-if="lastPing" class="ping-info">最后心跳: {{ lastPing }}</div>
|
||||
|
||||
<div class="rules-section">
|
||||
<div class="section-header">
|
||||
<h3>代理规则</h3>
|
||||
<div v-if="!editing">
|
||||
<button class="btn primary" @click="startEdit">编辑</button>
|
||||
<button class="btn danger" @click="confirmDelete">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 规则卡片 -->
|
||||
<n-card title="代理规则">
|
||||
<template #header-extra v-if="editing">
|
||||
<n-space>
|
||||
<n-button @click="cancelEdit">
|
||||
<template #icon><n-icon><CloseOutline /></n-icon></template>
|
||||
取消
|
||||
</n-button>
|
||||
<n-button type="primary" @click="saveEdit">
|
||||
<template #icon><n-icon><SaveOutline /></n-icon></template>
|
||||
保存
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<!-- 查看模式 -->
|
||||
<table v-if="!editing" class="rules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>本地地址</th>
|
||||
<th>远程端口</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rule in rules" :key="rule.name">
|
||||
<td>{{ rule.name }}</td>
|
||||
<td>{{ rule.local_ip }}:{{ rule.local_port }}</td>
|
||||
<td>{{ rule.remote_port }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<template v-if="!editing">
|
||||
<n-empty v-if="rules.length === 0" description="暂无代理规则" />
|
||||
<n-table v-else :bordered="false" :single-line="false">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>本地地址</th>
|
||||
<th>远程端口</th>
|
||||
<th>类型</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rule in rules" :key="rule.name">
|
||||
<td>{{ rule.name || '未命名' }}</td>
|
||||
<td>{{ rule.local_ip }}:{{ rule.local_port }}</td>
|
||||
<td>{{ rule.remote_port }}</td>
|
||||
<td><n-tag size="small">{{ rule.type || 'tcp' }}</n-tag></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
</template>
|
||||
|
||||
<!-- 编辑模式 -->
|
||||
<div v-if="editing" class="edit-form">
|
||||
<div v-for="(rule, i) in editRules" :key="i" class="rule-row">
|
||||
<input v-model="rule.name" placeholder="名称" />
|
||||
<input v-model="rule.local_ip" placeholder="本地IP" />
|
||||
<input v-model.number="rule.local_port" type="number" placeholder="本地端口" />
|
||||
<input v-model.number="rule.remote_port" type="number" placeholder="远程端口" />
|
||||
<button class="btn-icon" @click="removeRule(i)">×</button>
|
||||
</div>
|
||||
<button class="btn secondary" @click="addRule">+ 添加规则</button>
|
||||
<div class="edit-actions">
|
||||
<button class="btn" @click="cancelEdit">取消</button>
|
||||
<button class="btn primary" @click="saveEdit">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<n-space vertical :size="12">
|
||||
<n-form-item label="昵称" :show-feedback="false">
|
||||
<n-input v-model:value="editNickname" placeholder="给客户端起个名字(可选)" style="max-width: 300px;" />
|
||||
</n-form-item>
|
||||
<n-card v-for="(rule, i) in editRules" :key="i" size="small">
|
||||
<n-space align="center">
|
||||
<n-form-item label="名称" :show-feedback="false">
|
||||
<n-input v-model:value="rule.name" placeholder="规则名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="类型" :show-feedback="false">
|
||||
<n-select v-model:value="rule.type" :options="typeOptions" style="width: 100px;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="本地IP" :show-feedback="false">
|
||||
<n-input v-model:value="rule.local_ip" placeholder="127.0.0.1" />
|
||||
</n-form-item>
|
||||
<n-form-item label="本地端口" :show-feedback="false">
|
||||
<n-input-number v-model:value="rule.local_port" :show-button="false" />
|
||||
</n-form-item>
|
||||
<n-form-item label="远程端口" :show-feedback="false">
|
||||
<n-input-number v-model:value="rule.remote_port" :show-button="false" />
|
||||
</n-form-item>
|
||||
<n-button v-if="editRules.length > 1" quaternary type="error" @click="removeRule(i)">
|
||||
<template #icon><n-icon><TrashOutline /></n-icon></template>
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
<n-button dashed block @click="addRule">
|
||||
<template #icon><n-icon><AddOutline /></n-icon></template>
|
||||
添加规则
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-card>
|
||||
|
||||
<!-- 安装插件模态框 -->
|
||||
<n-modal v-model:show="showInstallModal" preset="card" title="安装插件到客户端" style="width: 500px;">
|
||||
<n-empty v-if="availablePlugins.length === 0" description="暂无可用插件" />
|
||||
<n-space v-else vertical :size="12">
|
||||
<n-card v-for="plugin in availablePlugins" :key="plugin.name" size="small">
|
||||
<n-space justify="space-between" align="center">
|
||||
<n-space vertical :size="4">
|
||||
<n-space align="center">
|
||||
<span style="font-weight: 500;">{{ plugin.name }}</span>
|
||||
<n-tag size="small">{{ getTypeLabel(plugin.type) }}</n-tag>
|
||||
</n-space>
|
||||
<span style="color: #666; font-size: 12px;">{{ plugin.description }}</span>
|
||||
</n-space>
|
||||
<n-checkbox
|
||||
:checked="selectedPlugins.includes(plugin.name)"
|
||||
@update:checked="(v: boolean) => {
|
||||
if (v) selectedPlugins.push(plugin.name)
|
||||
else selectedPlugins = selectedPlugins.filter(n => n !== plugin.name)
|
||||
}"
|
||||
/>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showInstallModal = false">取消</n-button>
|
||||
<n-button type="primary" @click="installPlugins" :disabled="selectedPlugins.length === 0">
|
||||
安装 ({{ selectedPlugins.length }})
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header h2 { margin: 0; }
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.status-badge.online { background: #d4edda; color: #155724; }
|
||||
.status-badge.offline { background: #f8d7da; color: #721c24; }
|
||||
.ping-info { color: #666; margin-bottom: 20px; }
|
||||
.rules-section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section-header h3 { margin: 0; }
|
||||
.section-header .btn { margin-left: 8px; }
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn.primary { background: #3498db; color: #fff; }
|
||||
.btn.secondary { background: #95a5a6; color: #fff; }
|
||||
.btn.danger { background: #e74c3c; color: #fff; }
|
||||
.rules-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.rules-table th, .rules-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.rules-table th { font-weight: 600; }
|
||||
.rule-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.rule-row input {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-icon {
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
width: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getClients, addClient } from '../api'
|
||||
import type { ClientStatus, ProxyRule } from '../types'
|
||||
import { NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi, NEmpty } from 'naive-ui'
|
||||
import { getClients } from '../api'
|
||||
import type { ClientStatus } from '../types'
|
||||
|
||||
const router = useRouter()
|
||||
const clients = ref<ClientStatus[]>([])
|
||||
const showModal = ref(false)
|
||||
const newClientId = ref('')
|
||||
const newRules = ref<ProxyRule[]>([])
|
||||
|
||||
const loadClients = async () => {
|
||||
try {
|
||||
@@ -19,33 +17,16 @@ const loadClients = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onlineClients = computed(() => {
|
||||
return clients.value.filter(client => client.online).length
|
||||
})
|
||||
|
||||
const totalRules = computed(() => {
|
||||
return clients.value.reduce((sum, client) => sum + client.rule_count, 0)
|
||||
})
|
||||
|
||||
onMounted(loadClients)
|
||||
|
||||
const openAddModal = () => {
|
||||
newClientId.value = ''
|
||||
newRules.value = [{ name: '', local_ip: '127.0.0.1', local_port: 80, remote_port: 8080 }]
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
const addRule = () => {
|
||||
newRules.value.push({ name: '', local_ip: '127.0.0.1', local_port: 80, remote_port: 8080 })
|
||||
}
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
newRules.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const saveClient = async () => {
|
||||
if (!newClientId.value) return
|
||||
try {
|
||||
await addClient({ id: newClientId.value, rules: newRules.value })
|
||||
showModal.value = false
|
||||
loadClients()
|
||||
} catch (e) {
|
||||
alert('添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
const viewClient = (id: string) => {
|
||||
router.push(`/client/${id}`)
|
||||
}
|
||||
@@ -53,139 +34,49 @@ const viewClient = (id: string) => {
|
||||
|
||||
<template>
|
||||
<div class="home">
|
||||
<div class="toolbar">
|
||||
<h2>客户端列表</h2>
|
||||
<button class="btn primary" @click="openAddModal">添加客户端</button>
|
||||
<div style="margin-bottom: 24px;">
|
||||
<h2 style="margin: 0 0 8px 0;">客户端管理</h2>
|
||||
<p style="margin: 0; color: #666;">查看已连接的隧道客户端</p>
|
||||
</div>
|
||||
|
||||
<div class="client-grid">
|
||||
<div v-for="client in clients" :key="client.id" class="client-card" @click="viewClient(client.id)">
|
||||
<div class="card-header">
|
||||
<span class="client-id">{{ client.id }}</span>
|
||||
<span :class="['status', client.online ? 'online' : 'offline']"></span>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<span>{{ client.rule_count }} 条规则</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;">
|
||||
<n-gi>
|
||||
<n-card>
|
||||
<n-statistic label="总客户端" :value="clients.length" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card>
|
||||
<n-statistic label="在线客户端" :value="onlineClients" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card>
|
||||
<n-statistic label="总规则数" :value="totalRules" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<div v-if="clients.length === 0" class="empty">暂无客户端配置</div>
|
||||
<n-empty v-if="clients.length === 0" description="暂无客户端连接" />
|
||||
|
||||
<!-- 添加客户端模态框 -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||
<div class="modal">
|
||||
<h3>添加客户端</h3>
|
||||
<div class="form-group">
|
||||
<label>客户端 ID</label>
|
||||
<input v-model="newClientId" placeholder="例如: client-a" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>代理规则</label>
|
||||
<div v-for="(rule, i) in newRules" :key="i" class="rule-row">
|
||||
<input v-model="rule.name" placeholder="名称" />
|
||||
<input v-model="rule.local_ip" placeholder="本地IP" />
|
||||
<input v-model.number="rule.local_port" type="number" placeholder="本地端口" />
|
||||
<input v-model.number="rule.remote_port" type="number" placeholder="远程端口" />
|
||||
<button class="btn-icon" @click="removeRule(i)">×</button>
|
||||
</div>
|
||||
<button class="btn secondary" @click="addRule">+ 添加规则</button>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" @click="showModal = false">取消</button>
|
||||
<button class="btn primary" @click="saveClient">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<n-grid v-else :cols="3" :x-gap="16" :y-gap="16" responsive="screen" cols-s="1" cols-m="2">
|
||||
<n-gi v-for="client in clients" :key="client.id">
|
||||
<n-card hoverable style="cursor: pointer;" @click="viewClient(client.id)">
|
||||
<n-space justify="space-between" align="center">
|
||||
<div>
|
||||
<h3 style="margin: 0 0 4px 0;">{{ client.nickname || client.id }}</h3>
|
||||
<p v-if="client.nickname" style="margin: 0 0 8px 0; color: #999; font-size: 12px;">{{ client.id }}</p>
|
||||
<n-space>
|
||||
<n-tag :type="client.online ? 'success' : 'default'" size="small">
|
||||
{{ client.online ? '在线' : '离线' }}
|
||||
</n-tag>
|
||||
<n-tag type="info" size="small">{{ client.rule_count }} 条规则</n-tag>
|
||||
</n-space>
|
||||
</div>
|
||||
<n-button size="small" @click.stop="viewClient(client.id)">查看详情</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.toolbar h2 { font-size: 18px; color: #2c3e50; }
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn.primary { background: #3498db; color: #fff; }
|
||||
.btn.secondary { background: #95a5a6; color: #fff; }
|
||||
.client-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.client-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.client-card:hover { transform: translateY(-2px); }
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.client-id { font-weight: 600; }
|
||||
.status { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.status.online { background: #27ae60; }
|
||||
.status.offline { background: #95a5a6; }
|
||||
.card-info { font-size: 14px; color: #666; }
|
||||
.empty { text-align: center; color: #999; padding: 40px; }
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
width: 500px;
|
||||
max-width: 90%;
|
||||
}
|
||||
.modal h3 { margin-bottom: 16px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; margin-bottom: 8px; font-weight: 500; }
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.rule-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.rule-row input { flex: 1; width: auto; }
|
||||
.btn-icon {
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
width: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
122
web/src/views/LoginView.vue
Normal file
122
web/src/views/LoginView.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NCard, NForm, NFormItem, NInput, NButton, NAlert } from 'naive-ui'
|
||||
import { login, setToken } from '../api'
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username.value || !password.value) {
|
||||
error.value = '请输入用户名和密码'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const { data } = await login(username.value, password.value)
|
||||
setToken(data.token)
|
||||
router.push('/')
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.error || '登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<n-card class="login-card" :bordered="false">
|
||||
<template #header>
|
||||
<div class="login-header">
|
||||
<h1 class="logo">GoTunnel</h1>
|
||||
<p class="subtitle">安全的内网穿透工具</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-form @submit.prevent="handleLogin">
|
||||
<n-form-item label="用户名">
|
||||
<n-input
|
||||
v-model:value="username"
|
||||
placeholder="请输入用户名"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="密码">
|
||||
<n-input
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:disabled="loading"
|
||||
show-password-on="click"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-alert v-if="error" type="error" :show-icon="true" style="margin-bottom: 16px;">
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
|
||||
<n-button
|
||||
type="primary"
|
||||
block
|
||||
:loading="loading"
|
||||
attr-type="submit"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</n-button>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="login-footer">欢迎使用 GoTunnel</div>
|
||||
</template>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #18a058;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
137
web/src/views/PluginsView.vue
Normal file
137
web/src/views/PluginsView.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
NCard, NButton, NSpace, NTag, NStatistic, NGrid, NGi,
|
||||
NEmpty, NSpin, NIcon, NSwitch, useMessage
|
||||
} from 'naive-ui'
|
||||
import { ArrowBackOutline, ExtensionPuzzleOutline } from '@vicons/ionicons5'
|
||||
import { getPlugins, enablePlugin, disablePlugin } from '../api'
|
||||
import type { PluginInfo } from '../types'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const plugins = ref<PluginInfo[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
const loadPlugins = async () => {
|
||||
try {
|
||||
const { data } = await getPlugins()
|
||||
plugins.value = data || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load plugins', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const proxyPlugins = computed(() =>
|
||||
plugins.value.filter(p => p.type === 'proxy')
|
||||
)
|
||||
|
||||
const appPlugins = computed(() =>
|
||||
plugins.value.filter(p => p.type === 'app')
|
||||
)
|
||||
|
||||
const togglePlugin = async (plugin: PluginInfo) => {
|
||||
try {
|
||||
if (plugin.enabled) {
|
||||
await disablePlugin(plugin.name)
|
||||
message.success(`已禁用 ${plugin.name}`)
|
||||
} else {
|
||||
await enablePlugin(plugin.name)
|
||||
message.success(`已启用 ${plugin.name}`)
|
||||
}
|
||||
plugin.enabled = !plugin.enabled
|
||||
} catch (e) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
proxy: '协议',
|
||||
app: '应用',
|
||||
service: '服务',
|
||||
tool: '工具'
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
const colors: Record<string, 'info' | 'success' | 'warning' | 'error' | 'default'> = {
|
||||
proxy: 'info',
|
||||
app: 'success',
|
||||
service: 'warning',
|
||||
tool: 'default'
|
||||
}
|
||||
return colors[type] || 'default'
|
||||
}
|
||||
|
||||
onMounted(loadPlugins)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugins-view">
|
||||
<n-space justify="space-between" align="center" style="margin-bottom: 24px;">
|
||||
<div>
|
||||
<h2 style="margin: 0 0 8px 0;">插件管理</h2>
|
||||
<p style="margin: 0; color: #666;">查看和管理已注册的插件</p>
|
||||
</div>
|
||||
<n-button quaternary @click="router.push('/')">
|
||||
<template #icon><n-icon><ArrowBackOutline /></n-icon></template>
|
||||
返回首页
|
||||
</n-button>
|
||||
</n-space>
|
||||
|
||||
<n-spin :show="loading">
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16" style="margin-bottom: 24px;">
|
||||
<n-gi>
|
||||
<n-card>
|
||||
<n-statistic label="总插件数" :value="plugins.length" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card>
|
||||
<n-statistic label="协议插件" :value="proxyPlugins.length" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card>
|
||||
<n-statistic label="应用插件" :value="appPlugins.length" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-empty v-if="!loading && plugins.length === 0" description="暂无插件" />
|
||||
|
||||
<n-grid v-else :cols="3" :x-gap="16" :y-gap="16" responsive="screen" cols-s="1" cols-m="2">
|
||||
<n-gi v-for="plugin in plugins" :key="plugin.name">
|
||||
<n-card hoverable>
|
||||
<template #header>
|
||||
<n-space align="center">
|
||||
<n-icon size="24" color="#18a058"><ExtensionPuzzleOutline /></n-icon>
|
||||
<span>{{ plugin.name }}</span>
|
||||
</n-space>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-switch :value="plugin.enabled" @update:value="togglePlugin(plugin)" />
|
||||
</template>
|
||||
<n-space vertical :size="8">
|
||||
<n-space>
|
||||
<n-tag size="small">v{{ plugin.version }}</n-tag>
|
||||
<n-tag size="small" :type="getTypeColor(plugin.type)">
|
||||
{{ getTypeLabel(plugin.type) }}
|
||||
</n-tag>
|
||||
<n-tag size="small" :type="plugin.source === 'builtin' ? 'default' : 'warning'">
|
||||
{{ plugin.source === 'builtin' ? '内置' : 'WASM' }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<p style="margin: 0; color: #666;">{{ plugin.description }}</p>
|
||||
</n-space>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</n-spin>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user