update
All checks were successful
Build Multi-Platform Binaries / build (push) Successful in 11m54s

This commit is contained in:
Flik
2025-12-26 17:14:54 +08:00
parent 4623a7f031
commit 549f9aaf26
63 changed files with 10266 additions and 740 deletions

View File

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

View File

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

View File

@@ -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')

View File

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

View File

@@ -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;
}
}

View File

@@ -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
}

View File

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

View File

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

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