This commit is contained in:
Flik
2025-12-26 19:31:56 +08:00
parent 549f9aaf26
commit 0b51e0e3cf
15 changed files with 127 additions and 7422 deletions

4
.gitignore vendored
View File

@@ -26,9 +26,11 @@ Thumbs.db
# 前端 node_modules # 前端 node_modules
web/node_modules/ web/node_modules/
# 前端构建产物 (源码在 web/dist嵌入用的在 pkg/webserver/dist) # 构建产物 (源码在 web/dist嵌入用的在 pkg/webserver/dist)
web/dist/ web/dist/
pkg/webserver/dist/ pkg/webserver/dist/
**/dist/**
build/**
# 日志 # 日志
*.log *.log

View File

@@ -1 +0,0 @@
import{k as n,S as r,V as o,U as t}from"./vue-vendor-k28cQfDw.js";const l={xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",viewBox:"0 0 512 512"},a=n({name:"ArrowBackOutline",render:function(i,e){return t(),r("svg",l,e[0]||(e[0]=[o("path",{fill:"none",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"48",d:"M244 400L100 256l144-144"},null,-1),o("path",{fill:"none",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"48",d:"M120 256h292"},null,-1)]))}});export{a as A};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{k as B,r as V,c as y,o as z,S as p,V as r,_ as l,X as f,Y as a,N as e,F as S,R as $,Z as j,U as u,a4 as F,$ as i,j as m,a3 as R}from"./vue-vendor-k28cQfDw.js";import{e as g,f as E,g as G,h as d,N as c,i as _,j as k,k as N,B as M}from"./index-BJ4y0MF5.js";const T={class:"home"},D={style:{margin:"0 0 4px 0"}},H={key:0,style:{margin:"0 0 8px 0",color:"#999","font-size":"12px"}},Y=B({__name:"HomeView",setup(L){const h=j(),n=V([]),x=async()=>{try{const{data:t}=await G();n.value=t||[]}catch(t){console.error("Failed to load clients",t)}},C=y(()=>n.value.filter(t=>t.online).length),b=y(()=>n.value.reduce((t,o)=>t+o.rule_count,0));z(x);const v=t=>{h.push(`/client/${t}`)};return(t,o)=>(u(),p("div",T,[o[1]||(o[1]=r("div",{style:{"margin-bottom":"24px"}},[r("h2",{style:{margin:"0 0 8px 0"}},"客户端管理"),r("p",{style:{margin:"0",color:"#666"}},"查看已连接的隧道客户端")],-1)),l(e(g),{cols:3,"x-gap":16,"y-gap":16,style:{"margin-bottom":"24px"}},{default:a(()=>[l(e(d),null,{default:a(()=>[l(e(c),null,{default:a(()=>[l(e(_),{label:"总客户端",value:n.value.length},null,8,["value"])]),_:1})]),_:1}),l(e(d),null,{default:a(()=>[l(e(c),null,{default:a(()=>[l(e(_),{label:"在线客户端",value:C.value},null,8,["value"])]),_:1})]),_:1}),l(e(d),null,{default:a(()=>[l(e(c),null,{default:a(()=>[l(e(_),{label:"总规则数",value:b.value},null,8,["value"])]),_:1})]),_:1})]),_:1}),n.value.length===0?(u(),f(e(E),{key:0,description:"暂无客户端连接"})):(u(),f(e(g),{key:1,cols:3,"x-gap":16,"y-gap":16,responsive:"screen","cols-s":"1","cols-m":"2"},{default:a(()=>[(u(!0),p(S,null,$(n.value,s=>(u(),f(e(d),{key:s.id},{default:a(()=>[l(e(c),{hoverable:"",style:{cursor:"pointer"},onClick:w=>v(s.id)},{default:a(()=>[l(e(k),{justify:"space-between",align:"center"},{default:a(()=>[r("div",null,[r("h3",D,i(s.nickname||s.id),1),s.nickname?(u(),p("p",H,i(s.id),1)):F("",!0),l(e(k),null,{default:a(()=>[l(e(N),{type:s.online?"success":"default",size:"small"},{default:a(()=>[m(i(s.online?"在线":"离线"),1)]),_:2},1032,["type"]),l(e(N),{type:"info",size:"small"},{default:a(()=>[m(i(s.rule_count)+" 条规则",1)]),_:2},1024)]),_:2},1024)]),l(e(M),{size:"small",onClick:R(w=>v(s.id),["stop"])},{default:a(()=>[...o[0]||(o[0]=[m("查看详情",-1)])]),_:1},8,["onClick"])]),_:2},1024)]),_:2},1032,["onClick"])]),_:2},1024))),128))]),_:1}))]))}});export{Y as default};

View File

@@ -1 +0,0 @@
import{k as N,r as d,S as k,_ as s,Y as a,N as t,Z as w,U as f,a3 as V,X as h,a4 as x,j as m,$ as g,V as i}from"./vue-vendor-k28cQfDw.js";import{N as B,a as C,b,c as _,d as T,B as I,l as L,s as S}from"./index-BJ4y0MF5.js";const U={class:"login-page"},F=N({__name:"LoginView",setup(v){const p=w(),l=d(""),r=d(""),o=d(""),n=d(!1),y=async()=>{if(!l.value||!r.value){o.value="请输入用户名和密码";return}n.value=!0,o.value="";try{const{data:u}=await L(l.value,r.value);S(u.token),p.push("/")}catch(u){o.value=u.response?.data?.error||"登录失败"}finally{n.value=!1}};return(u,e)=>(f(),k("div",U,[s(t(B),{class:"login-card",bordered:!1},{header:a(()=>[...e[2]||(e[2]=[i("div",{class:"login-header"},[i("h1",{class:"logo"},"GoTunnel"),i("p",{class:"subtitle"},"安全的内网穿透工具")],-1)])]),footer:a(()=>[...e[3]||(e[3]=[i("div",{class:"login-footer"},"欢迎使用 GoTunnel",-1)])]),default:a(()=>[s(t(C),{onSubmit:V(y,["prevent"])},{default:a(()=>[s(t(b),{label:"用户名"},{default:a(()=>[s(t(_),{value:l.value,"onUpdate:value":e[0]||(e[0]=c=>l.value=c),placeholder:"请输入用户名",disabled:n.value},null,8,["value","disabled"])]),_:1}),s(t(b),{label:"密码"},{default:a(()=>[s(t(_),{value:r.value,"onUpdate:value":e[1]||(e[1]=c=>r.value=c),type:"password",placeholder:"请输入密码",disabled:n.value,"show-password-on":"click"},null,8,["value","disabled"])]),_:1}),o.value?(f(),h(t(T),{key:0,type:"error","show-icon":!0,style:{"margin-bottom":"16px"}},{default:a(()=>[m(g(o.value),1)]),_:1})):x("",!0),s(t(I),{type:"primary",block:"",loading:n.value,"attr-type":"submit"},{default:a(()=>[m(g(n.value?"登录中...":"登录"),1)]),_:1},8,["loading"])]),_:1})]),_:1})]))}}),G=(v,p)=>{const l=v.__vccOpts||v;for(const[r,o]of p)l[r]=o;return l},D=G(F,[["__scopeId","data-v-0e29b44b"]]);export{D as default};

View File

@@ -1 +0,0 @@
.login-page[data-v-0e29b44b]{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#e8f5e9,#c8e6c9);padding:16px}.login-card[data-v-0e29b44b]{width:100%;max-width:400px;box-shadow:0 8px 24px #0000001a}.login-header[data-v-0e29b44b]{text-align:center}.logo[data-v-0e29b44b]{font-size:28px;font-weight:700;color:#18a058;margin:0 0 8px}.subtitle[data-v-0e29b44b]{color:#666;margin:0;font-size:14px}.login-footer[data-v-0e29b44b]{text-align:center;color:#999;font-size:14px}

View File

@@ -1 +0,0 @@
import{k as $,r as b,c as x,o as E,S as N,_ as a,Y as l,N as e,V as u,Z as F,j as c,X as m,F as T,R as j,U as r,$ as i}from"./vue-vendor-k28cQfDw.js";import{u as A,j as d,D as G,y as M,B as U,p as h,e as w,h as p,N as f,i as _,f as D,k as g,E as L,F as O,G as R,H as q}from"./index-BJ4y0MF5.js";import{A as H}from"./ArrowBackOutline-QaNKMlLc.js";const I={class:"plugins-view"},W={style:{margin:"0",color:"#666"}},Q=$({__name:"PluginsView",setup(X){const k=F(),v=A(),o=b([]),y=b(!0),P=async()=>{try{const{data:t}=await M();o.value=t||[]}catch(t){console.error("Failed to load plugins",t)}finally{y.value=!1}},z=x(()=>o.value.filter(t=>t.type==="proxy")),B=x(()=>o.value.filter(t=>t.type==="app")),S=async t=>{try{t.enabled?(await R(t.name),v.success(`已禁用 ${t.name}`)):(await q(t.name),v.success(`已启用 ${t.name}`)),t.enabled=!t.enabled}catch{v.error("操作失败")}},C=t=>({proxy:"协议",app:"应用",service:"服务",tool:"工具"})[t]||t,V=t=>({proxy:"info",app:"success",service:"warning",tool:"default"})[t]||"default";return E(P),(t,n)=>(r(),N("div",I,[a(e(d),{justify:"space-between",align:"center",style:{"margin-bottom":"24px"}},{default:l(()=>[n[2]||(n[2]=u("div",null,[u("h2",{style:{margin:"0 0 8px 0"}},"插件管理"),u("p",{style:{margin:"0",color:"#666"}},"查看和管理已注册的插件")],-1)),a(e(U),{quaternary:"",onClick:n[0]||(n[0]=s=>e(k).push("/"))},{icon:l(()=>[a(e(h),null,{default:l(()=>[a(e(H))]),_:1})]),default:l(()=>[n[1]||(n[1]=c(" 返回首页 ",-1))]),_:1})]),_:1}),a(e(G),{show:y.value},{default:l(()=>[a(e(w),{cols:3,"x-gap":16,"y-gap":16,style:{"margin-bottom":"24px"}},{default:l(()=>[a(e(p),null,{default:l(()=>[a(e(f),null,{default:l(()=>[a(e(_),{label:"总插件数",value:o.value.length},null,8,["value"])]),_:1})]),_:1}),a(e(p),null,{default:l(()=>[a(e(f),null,{default:l(()=>[a(e(_),{label:"协议插件",value:z.value.length},null,8,["value"])]),_:1})]),_:1}),a(e(p),null,{default:l(()=>[a(e(f),null,{default:l(()=>[a(e(_),{label:"应用插件",value:B.value.length},null,8,["value"])]),_:1})]),_:1})]),_:1}),!y.value&&o.value.length===0?(r(),m(e(D),{key:0,description:"暂无插件"})):(r(),m(e(w),{key:1,cols:3,"x-gap":16,"y-gap":16,responsive:"screen","cols-s":"1","cols-m":"2"},{default:l(()=>[(r(!0),N(T,null,j(o.value,s=>(r(),m(e(p),{key:s.name},{default:l(()=>[a(e(f),{hoverable:""},{header:l(()=>[a(e(d),{align:"center"},{default:l(()=>[a(e(h),{size:"24",color:"#18a058"},{default:l(()=>[a(e(O))]),_:1}),u("span",null,i(s.name),1)]),_:2},1024)]),"header-extra":l(()=>[a(e(L),{value:s.enabled,"onUpdate:value":Y=>S(s)},null,8,["value","onUpdate:value"])]),default:l(()=>[a(e(d),{vertical:"",size:8},{default:l(()=>[a(e(d),null,{default:l(()=>[a(e(g),{size:"small"},{default:l(()=>[c("v"+i(s.version),1)]),_:2},1024),a(e(g),{size:"small",type:V(s.type)},{default:l(()=>[c(i(C(s.type)),1)]),_:2},1032,["type"]),a(e(g),{size:"small",type:s.source==="builtin"?"default":"warning"},{default:l(()=>[c(i(s.source==="builtin"?"内置":"WASM"),1)]),_:2},1032,["type"])]),_:2},1024),u("p",W,i(s.description),1)]),_:2},1024)]),_:2},1024)]),_:2},1024))),128))]),_:1}))]),_:1},8,["show"])]))}});export{Q as default};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
*{margin:0;padding:0;box-sizing:border-box}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#app{min-height:100vh}

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title>
<script type="module" crossorigin src="/assets/index-BJ4y0MF5.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vue-vendor-k28cQfDw.js">
<link rel="stylesheet" crossorigin href="/assets/index-cn54chxY.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -1 +0,0 @@
<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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, h } from 'vue' import { ref, onMounted, computed, h, watch } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router' import { RouterView, useRouter, useRoute } from 'vue-router'
import { NLayout, NLayoutHeader, NLayoutContent, NMenu, NButton, NSpace, NTag, NIcon, NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui' import { NLayout, NLayoutHeader, NLayoutContent, NMenu, NButton, NSpace, NTag, NIcon, NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui'
import { HomeOutline, ExtensionPuzzleOutline, LogOutOutline } from '@vicons/ionicons5' import { HomeOutline, ExtensionPuzzleOutline, LogOutOutline } from '@vicons/ionicons5'
import type { MenuOption } from 'naive-ui' import type { MenuOption } from 'naive-ui'
import { getServerStatus, removeToken } from './api' import { getServerStatus, removeToken, getToken } from './api'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -35,8 +35,8 @@ const handleMenuUpdate = (key: string) => {
router.push(key) router.push(key)
} }
onMounted(async () => { const fetchServerStatus = async () => {
if (isLoginPage.value) return if (isLoginPage.value || !getToken()) return
try { try {
const { data } = await getServerStatus() const { data } = await getServerStatus()
serverInfo.value = data.server serverInfo.value = data.server
@@ -44,6 +44,17 @@ onMounted(async () => {
} catch (e) { } catch (e) {
console.error('Failed to get server status', e) console.error('Failed to get server status', e)
} }
}
// 监听路由变化,离开登录页时获取状态
watch(() => route.path, (newPath, oldPath) => {
if (oldPath === '/login' && newPath !== '/login') {
fetchServerStatus()
}
})
onMounted(() => {
fetchServerStatus()
}) })
const logout = () => { const logout = () => {

View File

@@ -1,61 +1,32 @@
import axios from 'axios' import { get, post, put, del } from '../config/axios'
import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo } from '../types' import type { ClientConfig, ClientStatus, ClientDetail, ServerStatus, PluginInfo } from '../types'
const api = axios.create({ // 重新导出 token 管理方法
baseURL: '/api', export { getToken, setToken, removeToken } from '../config/axios'
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 // 认证 API
export const login = (username: string, password: string) => export const login = (username: string, password: string) =>
api.post<{ token: string }>('/auth/login', { username, password }) post<{ token: string }>('/auth/login', { username, password })
export const checkAuth = () => api.get('/auth/check') export const checkAuth = () => get('/auth/check')
export const getServerStatus = () => api.get<ServerStatus>('/status') // 服务器状态
export const getClients = () => api.get<ClientStatus[]>('/clients') export const getServerStatus = () => get<ServerStatus>('/status')
export const getClient = (id: string) => api.get<ClientDetail>(`/client/${id}`)
export const addClient = (client: ClientConfig) => api.post('/clients', client) // 客户端管理
export const updateClient = (id: string, client: ClientConfig) => api.put(`/client/${id}`, client) export const getClients = () => get<ClientStatus[]>('/clients')
export const deleteClient = (id: string) => api.delete(`/client/${id}`) export const getClient = (id: string) => get<ClientDetail>(`/client/${id}`)
export const reloadConfig = () => api.post('/config/reload') export const addClient = (client: ClientConfig) => post('/clients', client)
export const updateClient = (id: string, client: ClientConfig) => put(`/client/${id}`, client)
export const deleteClient = (id: string) => del(`/client/${id}`)
export const reloadConfig = () => post('/config/reload')
// 客户端控制 // 客户端控制
export const pushConfigToClient = (id: string) => api.post(`/client/${id}/push`) export const pushConfigToClient = (id: string) => post(`/client/${id}/push`)
export const disconnectClient = (id: string) => api.post(`/client/${id}/disconnect`) export const disconnectClient = (id: string) => post(`/client/${id}/disconnect`)
export const installPluginsToClient = (id: string, plugins: string[]) => export const installPluginsToClient = (id: string, plugins: string[]) =>
api.post(`/client/${id}/install-plugins`, { plugins }) post(`/client/${id}/install-plugins`, { plugins })
// 插件管理 // 插件管理
export const getPlugins = () => api.get<PluginInfo[]>('/plugins') export const getPlugins = () => get<PluginInfo[]>('/plugins')
export const enablePlugin = (name: string) => api.post(`/plugin/${name}/enable`) export const enablePlugin = (name: string) => post(`/plugin/${name}/enable`)
export const disablePlugin = (name: string) => api.post(`/plugin/${name}/disable`) export const disablePlugin = (name: string) => post(`/plugin/${name}/disable`)
export default api

View File

@@ -0,0 +1,88 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
// Token 管理
const TOKEN_KEY = 'gotunnel_token'
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY)
export const setToken = (token: string): void => localStorage.setItem(TOKEN_KEY, token)
export const removeToken = (): void => localStorage.removeItem(TOKEN_KEY)
// 创建 axios 实例
const instance: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 10000,
})
// 防止重复跳转
let isRedirecting = false
// 请求拦截器
instance.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
instance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && !isRedirecting) {
isRedirecting = true
removeToken()
setTimeout(() => {
window.location.replace('/login')
isRedirecting = false
}, 0)
}
return Promise.reject(error)
}
)
// 请求方法封装
export const get = <T = any>(
url: string,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> => {
return instance.get<T>(url, config)
}
export const post = <T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> => {
return instance.post<T>(url, data, config)
}
export const put = <T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> => {
return instance.put<T>(url, data, config)
}
export const del = <T = any>(
url: string,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> => {
return instance.delete<T>(url, config)
}
export const patch = <T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<AxiosResponse<T>> => {
return instance.patch<T>(url, data, config)
}
export default instance