feat: add AGENTS.md for build commands and architecture overview
All checks were successful
Build Multi-Platform Binaries / build-frontend (push) Successful in 27s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m16s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m34s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m8s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m51s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m6s
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Successful in 1m44s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 2m4s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m3s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m51s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m23s

This commit is contained in:
2026-03-17 23:54:44 +08:00
parent 838bde28f0
commit 937536e422
3 changed files with 128 additions and 400 deletions

114
AGENTS.md Normal file
View File

@@ -0,0 +1,114 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Build Commands
```bash
# Build server and client binaries
go build -o server ./cmd/server
go build -o client ./cmd/client
# Run server (zero-config, auto-generates token and TLS cert)
./server
./server -c server.yaml # with config file
# Run client
./client -s <server>:7000 -t <token> -id <client-id>
./client -s <server>:7000 -t <token> -id <client-id> -no-tls # disable TLS
# Web UI development (in web/ directory)
cd web && npm install && npm run dev # development server
cd web && npm run build # production build (outputs to web/dist/)
# Cross-platform build (Windows PowerShell)
.\scripts\build.ps1
# Cross-platform build (Linux/Mac)
./scripts/build.sh all
```
## Architecture Overview
GoTunnel is an intranet penetration tool (similar to frp) with **server-centric configuration** - clients require zero configuration and receive mapping rules from the server after authentication.
### Core Design
- **Yamux Multiplexing**: Single TCP connection carries both control (auth, config, heartbeat) and data channels
- **Binary Protocol**: `[Type(1 byte)][Length(4 bytes)][Payload(JSON)]` - see `pkg/protocol/message.go`
- **TLS by Default**: Auto-generated self-signed ECDSA P-256 certificates, no manual setup required
- **Embedded Web UI**: Vue.js SPA embedded in server binary via `//go:embed`
- **JS Plugin System**: Extensible plugin system using goja JavaScript runtime
### Package Structure
```
cmd/server/ # Server entry point
cmd/client/ # Client entry point
internal/server/
├── tunnel/ # Core tunnel server, client session management
├── config/ # YAML configuration loading
├── db/ # SQLite storage (ClientStore, JSPluginStore interfaces)
├── app/ # Web server, SPA handler
├── router/ # REST API endpoints (Swagger documented)
└── plugin/ # Server-side JS plugin manager
internal/client/
└── tunnel/ # Client tunnel logic, auto-reconnect, plugin execution
pkg/
├── protocol/ # Message types and serialization
├── crypto/ # TLS certificate generation
├── relay/ # Bidirectional data relay (32KB buffers)
├── auth/ # JWT authentication
├── utils/ # Port availability checking
├── version/ # Version info and update checking (Gitea API)
└── update/ # Shared update logic (download, extract tar.gz/zip)
web/ # Vue 3 + TypeScript frontend (Vite + naive-ui)
scripts/ # Build scripts (build.sh, build.ps1)
```
### Key Interfaces
- `ClientStore` (internal/server/db/): Database abstraction for client rules storage
- `ServerInterface` (internal/server/router/handler/): API handler interface
### Proxy Types
1. **TCP** (default): Direct port forwarding (remote_port → local_ip:local_port)
2. **UDP**: UDP port forwarding
3. **HTTP**: HTTP proxy through client network
4. **HTTPS**: HTTPS proxy through client network
5. **SOCKS5**: SOCKS5 proxy through client network
### Data Flow
External User → Server Port → Yamux Stream → Client → Local Service
### Configuration
- Server: YAML config + SQLite database for client rules and JS plugins
- Client: Command-line flags only (server address, token, client ID)
- Default ports: 7000 (tunnel), 7500 (web console)
## API Documentation
The server provides Swagger-documented REST APIs at `/api/`.
### Key Endpoints
- `POST /api/auth/login` - JWT authentication
- `GET /api/clients` - List all clients
- `GET /api/client/{id}` - Get client details
- `PUT /api/client/{id}` - Update client config
- `POST /api/client/{id}/push` - Push config to online client
- `POST /api/client/{id}/plugin/{name}/{action}` - Plugin actions (start/stop/restart/delete)
- `GET /api/plugins` - List registered plugins
- `GET /api/update/check/server` - Check server updates
- `POST /api/update/apply/server` - Apply server update
## Update System
Both server and client support self-update from Gitea releases.
- Release assets are compressed archives (`.tar.gz` for Linux/Mac, `.zip` for Windows)
- The `pkg/update/` package handles download, extraction, and binary replacement
- Updates can be triggered from the Web UI at `/update` page

7
web/package-lock.json generated
View File

@@ -1217,7 +1217,6 @@
"integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -1518,7 +1517,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -2296,7 +2294,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2323,7 +2320,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -2444,7 +2440,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -2497,7 +2492,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -2579,7 +2573,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.27", "@vue/compiler-dom": "3.5.27",
"@vue/compiler-sfc": "3.5.27", "@vue/compiler-sfc": "3.5.27",

View File

@@ -3,8 +3,7 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { import {
ArrowBackOutline, CreateOutline, TrashOutline, ArrowBackOutline, CreateOutline, TrashOutline,
PushOutline, AddOutline, StorefrontOutline, PushOutline, AddOutline, RefreshOutline,
ExtensionPuzzleOutline, SettingsOutline, RefreshOutline,
PlayOutline PlayOutline
} from '@vicons/ionicons5' } from '@vicons/ionicons5'
import GlassModal from '../components/GlassModal.vue' import GlassModal from '../components/GlassModal.vue'
@@ -14,13 +13,11 @@ import { useToast } from '../composables/useToast'
import { useConfirm } from '../composables/useConfirm' import { useConfirm } from '../composables/useConfirm'
import { import {
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient, getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient,
getClientPluginConfig, updateClientPluginConfig,
getStorePlugins, installStorePlugin, getRuleSchemas, startClientPlugin, restartClientPlugin, stopClientPlugin, deleteClientPlugin,
checkClientUpdate, applyClientUpdate, getClientSystemStats, getVersionInfo, checkClientUpdate, applyClientUpdate, getClientSystemStats, getVersionInfo,
getClientScreenshot, executeClientShell, getClientScreenshot, executeClientShell,
type UpdateInfo, type SystemStats, type ScreenshotData type UpdateInfo, type SystemStats, type ScreenshotData
} from '../api' } from '../api'
import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types' import type { ProxyRule } from '../types'
import InlineLogPanel from '../components/InlineLogPanel.vue' import InlineLogPanel from '../components/InlineLogPanel.vue'
const route = useRoute() const route = useRoute()
@@ -35,7 +32,6 @@ const lastPing = ref('')
const remoteAddr = ref('') const remoteAddr = ref('')
const nickname = ref('') const nickname = ref('')
const rules = ref<ProxyRule[]>([]) const rules = ref<ProxyRule[]>([])
const clientPlugins = ref<ClientPlugin[]>([])
const loading = ref(false) const loading = ref(false)
const clientOs = ref('') const clientOs = ref('')
const clientArch = ref('') const clientArch = ref('')
@@ -65,17 +61,6 @@ const serverVersion = ref('')
const shellHistory = ref<string[]>([]) const shellHistory = ref<string[]>([])
const historyIndex = ref(-1) const historyIndex = ref(-1)
// Rule Schemas
const pluginRuleSchemas = ref<RuleSchemasMap>({})
const loadRuleSchemas = async () => {
try {
const { data } = await getRuleSchemas()
pluginRuleSchemas.value = data || {}
} catch (e) {
console.error('Failed to load rule schemas', e)
}
}
// Built-in Types (Added WebSocket) // Built-in Types (Added WebSocket)
const builtinTypes = [ const builtinTypes = [
{ label: 'TCP', value: 'tcp' }, { label: 'TCP', value: 'tcp' },
@@ -96,20 +81,13 @@ const defaultRule = {
local_port: 80, local_port: 80,
remote_port: 0, remote_port: 0,
type: 'tcp', type: 'tcp',
enabled: true, enabled: true
plugin_config: {} as Record<string, string>
} }
const ruleForm = ref<ProxyRule>({ ...defaultRule }) const ruleForm = ref<ProxyRule>({ ...defaultRule })
// Helper: Check if type needs local addr // Helper: Check if type needs local addr
const needsLocalAddr = (type: string) => { const needsLocalAddr = () => {
const schema = pluginRuleSchemas.value[type] return true
return schema?.needs_local_addr ?? true
}
const getExtraFields = (type: string): ConfigField[] => {
const schema = pluginRuleSchemas.value[type]
return schema?.extra_fields || []
} }
// 加载服务端版本 // 加载服务端版本
@@ -177,7 +155,6 @@ const loadClient = async () => {
remoteAddr.value = data.remote_addr || '' remoteAddr.value = data.remote_addr || ''
nickname.value = data.nickname || '' nickname.value = data.nickname || ''
rules.value = data.rules || [] rules.value = data.rules || []
clientPlugins.value = data.plugins || []
clientOs.value = data.os || '' clientOs.value = data.os || ''
clientArch.value = data.arch || '' clientArch.value = data.arch || ''
clientVersion.value = data.version || '' clientVersion.value = data.version || ''
@@ -368,7 +345,6 @@ const openCreateRule = () => {
} }
const openEditRule = (rule: ProxyRule) => { const openEditRule = (rule: ProxyRule) => {
if (rule.plugin_managed) return
ruleModalType.value = 'edit' ruleModalType.value = 'edit'
ruleForm.value = JSON.parse(JSON.stringify(rule)) ruleForm.value = JSON.parse(JSON.stringify(rule))
showRuleModal.value = true showRuleModal.value = true
@@ -416,7 +392,7 @@ const handleRuleSubmit = async () => {
message.error('请输入有效的远程端口 (1-65535)') message.error('请输入有效的远程端口 (1-65535)')
return return
} }
if (needsLocalAddr(ruleForm.value.type || 'tcp')) { if (needsLocalAddr()) {
if (!ruleForm.value.local_ip) { if (!ruleForm.value.local_ip) {
message.error('请输入本地IP') message.error('请输入本地IP')
return return
@@ -444,126 +420,6 @@ const handleRuleSubmit = async () => {
showRuleModal.value = false showRuleModal.value = false
} }
// Store & Plugin Logic
const showStoreModal = ref(false)
const storePlugins = ref<StorePluginInfo[]>([])
const storeLoading = ref(false)
const storeInstalling = ref<string | null>(null)
const showInstallConfigModal = ref(false)
const installPlugin = ref<StorePluginInfo | null>(null)
const installRemotePort = ref<number | null>(8080)
const installAuthEnabled = ref(false)
const installAuthUsername = ref('')
const installAuthPassword = ref('')
const openStoreModal = async () => {
showStoreModal.value = true
storeLoading.value = true
try {
const { data } = await getStorePlugins()
storePlugins.value = (data.plugins || []).filter((p: any) => p.download_url)
} catch (e) {
message.error('加载商店失败')
} finally {
storeLoading.value = false
}
}
const handleInstallStorePlugin = (plugin: StorePluginInfo) => {
installPlugin.value = plugin
installRemotePort.value = 8080
showInstallConfigModal.value = true
}
const confirmInstallPlugin = async () => {
if (!installPlugin.value) return
storeInstalling.value = installPlugin.value.name
try {
await installStorePlugin(
installPlugin.value.name,
installPlugin.value.download_url || '',
installPlugin.value.signature_url || '',
clientId,
installRemotePort.value || 8080,
installPlugin.value.version,
installPlugin.value.config_schema,
installAuthEnabled.value,
installAuthUsername.value,
installAuthPassword.value
)
message.success(`已安装 ${installPlugin.value.name}`)
showInstallConfigModal.value = false
showStoreModal.value = false
await loadClient()
} catch (e: any) {
message.error(e.response?.data || '安装失败')
} finally {
storeInstalling.value = null
}
}
// Plugin Actions
const handleOpenPlugin = (plugin: ClientPlugin) => {
if (!plugin.remote_port) return
const hostname = window.location.hostname
const url = `http://${hostname}:${plugin.remote_port}`
window.open(url, '_blank')
}
const toggleClientPlugin = async (plugin: ClientPlugin) => {
const newEnabled = !plugin.enabled
const updatedPlugins = clientPlugins.value.map(p =>
p.id === plugin.id ? { ...p, enabled: newEnabled } : p
)
try {
await updateClient(clientId, {
id: clientId,
nickname: nickname.value,
rules: rules.value,
plugins: updatedPlugins
})
plugin.enabled = newEnabled
message.success(newEnabled ? `已启用 ${plugin.name}` : `已禁用 ${plugin.name}`)
} catch (e) {
message.error('操作失败')
}
}
// Plugin Config Modal
const showConfigModal = ref(false)
const configPluginName = ref('')
const configSchema = ref<ConfigField[]>([])
const configValues = ref<Record<string, string>>({})
const configLoading = ref(false)
const openConfigModal = async (plugin: ClientPlugin) => {
configPluginName.value = plugin.name
configLoading.value = true
showConfigModal.value = true
try {
const { data } = await getClientPluginConfig(clientId, plugin.name)
configSchema.value = data.schema || []
configValues.value = { ...data.config }
configSchema.value.forEach(f => {
if (f.default && !configValues.value[f.key]) {
configValues.value[f.key] = f.default
}
})
} catch (e) {
message.error('加载配置失败')
showConfigModal.value = false
} finally {
configLoading.value = false
}
}
const savePluginConfig = async () => {
try {
await updateClientPluginConfig(clientId, configPluginName.value, configValues.value)
message.success('配置已保存')
showConfigModal.value = false
loadClient()
} catch (e: any) {
message.error(e.response?.data || '保存失败')
}
}
// Standard Client Actions // Standard Client Actions
const confirmDelete = () => { const confirmDelete = () => {
dialog.warning({ dialog.warning({
@@ -597,7 +453,6 @@ const handleRestartClient = () => {
const pollTimer = ref<number | null>(null) const pollTimer = ref<number | null>(null)
onMounted(() => { onMounted(() => {
loadRuleSchemas()
loadServerVersion() loadServerVersion()
loadClient() loadClient()
// 启动自动轮询,每 5 秒刷新一次 // 启动自动轮询,每 5 秒刷新一次
@@ -611,39 +466,11 @@ onUnmounted(() => {
clearInterval(pollTimer.value) clearInterval(pollTimer.value)
pollTimer.value = null pollTimer.value = null
} }
}) if (screenshotTimer.value) {
clearInterval(screenshotTimer.value)
// Plugin Menu screenshotTimer.value = null
const activePluginMenu = ref('')
const togglePluginMenu = (pluginId: string) => {
activePluginMenu.value = activePluginMenu.value === pluginId ? '' : pluginId
}
// Plugin Status Actions
const handleStartPlugin = async (plugin: ClientPlugin) => {
const rule = rules.value.find(r => r.type === plugin.name)
const ruleName = rule?.name || plugin.name
try { await startClientPlugin(clientId, plugin.id, ruleName); message.success('已启动'); plugin.running = true } catch(e:any){ message.error(e.message) }
}
const handleRestartPlugin = async (plugin: ClientPlugin) => {
const rule = rules.value.find(r => r.type === plugin.name)
const ruleName = rule?.name || plugin.name
try { await restartClientPlugin(clientId, plugin.id, ruleName); message.success('已重启'); plugin.running = true } catch(e:any){ message.error(e.message)}
}
const handleStopPlugin = async (plugin: ClientPlugin) => {
const rule = rules.value.find(r => r.type === plugin.name)
const ruleName = rule?.name || plugin.name
try { await stopClientPlugin(clientId, plugin.id, ruleName); message.success('已停止'); plugin.running = false } catch(e:any){ message.error(e.message)}
}
const handleDeletePlugin = (plugin: ClientPlugin) => {
dialog.warning({
title: '确认删除', content: `确定要删除插件 ${plugin.name} 吗?`,
positiveText: '删除', negativeText: '取消',
onPositiveClick: async () => {
await deleteClientPlugin(clientId, plugin.id); message.success('已删除'); loadClient()
} }
}) })
}
</script> </script>
<template> <template>
@@ -737,10 +564,6 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<span class="mini-stat-value">{{ rules.length }}</span> <span class="mini-stat-value">{{ rules.length }}</span>
<span class="mini-stat-label">规则数</span> <span class="mini-stat-label">规则数</span>
</div> </div>
<div class="mini-stat">
<span class="mini-stat-value">{{ clientPlugins.length }}</span>
<span class="mini-stat-label">插件数</span>
</div>
</div> </div>
</div> </div>
@@ -876,7 +699,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<span class="rule-name">{{ rule.name }}</span> <span class="rule-name">{{ rule.name }}</span>
<span><GlassTag :type="rule.type==='websocket'?'info':'default'">{{ (rule.type || 'tcp').toUpperCase() }}</GlassTag></span> <span><GlassTag :type="rule.type==='websocket'?'info':'default'">{{ (rule.type || 'tcp').toUpperCase() }}</GlassTag></span>
<span class="rule-mapping"> <span class="rule-mapping">
{{ needsLocalAddr(rule.type||'tcp') ? `${rule.local_ip}:${rule.local_port}` : '-' }} {{ needsLocalAddr() ? `${rule.local_ip}:${rule.local_port}` : '-' }}
:{{ rule.remote_port }} :{{ rule.remote_port }}
</span> </span>
@@ -884,64 +707,14 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<GlassSwitch :model-value="rule.enabled !== false" @update:model-value="(v: boolean) => { rule.enabled = v; saveRules(rules) }" size="small" /> <GlassSwitch :model-value="rule.enabled !== false" @update:model-value="(v: boolean) => { rule.enabled = v; saveRules(rules) }" size="small" />
</span> </span>
<span class="rule-actions"> <span class="rule-actions">
<GlassTag v-if="rule.plugin_managed" type="info" title="此规则由插件管理">插件托管</GlassTag>
<template v-else>
<button class="icon-btn" @click="openEditRule(rule)">编辑</button> <button class="icon-btn" @click="openEditRule(rule)">编辑</button>
<button class="icon-btn danger" @click="handleDeleteRule(rule)">删除</button> <button class="icon-btn danger" @click="handleDeleteRule(rule)">删除</button>
</template>
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Plugins Card -->
<div class="glass-card">
<div class="card-header">
<h3>已安装扩展</h3>
<button class="glass-btn small" @click="openStoreModal">
<StorefrontOutline class="btn-icon" />
插件商店
</button>
</div>
<div class="card-body">
<div v-if="clientPlugins.length === 0" class="empty-state">
<p>暂无安装的扩展</p>
</div>
<div v-else class="plugins-list">
<div v-for="plugin in clientPlugins" :key="plugin.id" class="plugin-item">
<div class="plugin-info">
<ExtensionPuzzleOutline class="plugin-icon" />
<span class="plugin-name">{{ plugin.name }}</span>
<span class="plugin-version">v{{ plugin.version }}</span>
</div>
<div class="plugin-meta">
<span>端口: {{ plugin.remote_port || '-' }}</span>
<GlassTag :type="plugin.running ? 'success' : 'default'" round>
{{ plugin.running ? '运行中' : '已停止' }}
</GlassTag>
<GlassSwitch :model-value="plugin.enabled" size="small" @update:model-value="toggleClientPlugin(plugin)" />
</div>
<div class="plugin-actions">
<button v-if="plugin.running && plugin.remote_port" class="icon-btn success" @click="handleOpenPlugin(plugin)">打开</button>
<button v-if="!plugin.running" class="icon-btn" @click="handleStartPlugin(plugin)" :disabled="!online || !plugin.enabled">启动</button>
<div class="dropdown-wrapper">
<button class="icon-btn" @click="togglePluginMenu(plugin.id)">
<SettingsOutline class="settings-icon" />
</button>
<div v-if="activePluginMenu === plugin.id" class="dropdown-menu">
<button @click="handleRestartPlugin(plugin); activePluginMenu = ''" :disabled="!plugin.running">重启</button>
<button @click="openConfigModal(plugin); activePluginMenu = ''">配置</button>
<button @click="handleStopPlugin(plugin); activePluginMenu = ''" :disabled="!plugin.running">停止</button>
<button class="danger" @click="handleDeletePlugin(plugin); activePluginMenu = ''">删除</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Inline Log Panel --> <!-- Inline Log Panel -->
<div class="glass-card"> <div class="glass-card">
<InlineLogPanel :client-id="clientId" /> <InlineLogPanel :client-id="clientId" />
@@ -962,7 +735,7 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<option v-for="t in builtinTypes" :key="t.value" :value="t.value">{{ t.label }}</option> <option v-for="t in builtinTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
</select> </select>
</div> </div>
<template v-if="needsLocalAddr(ruleForm.type || 'tcp')"> <template v-if="needsLocalAddr()">
<div class="form-group"> <div class="form-group">
<label class="form-label">本地IP</label> <label class="form-label">本地IP</label>
<input v-model="ruleForm.local_ip" class="form-input" placeholder="127.0.0.1" /> <input v-model="ruleForm.local_ip" class="form-input" placeholder="127.0.0.1" />
@@ -976,43 +749,12 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<label class="form-label">远程端口</label> <label class="form-label">远程端口</label>
<input v-model.number="ruleForm.remote_port" type="number" class="form-input" min="1" max="65535" /> <input v-model.number="ruleForm.remote_port" type="number" class="form-input" min="1" max="65535" />
</div> </div>
<template v-for="field in getExtraFields(ruleForm.type || '')" :key="field.key">
<div class="form-group">
<label class="form-label">{{ field.label }}</label>
<input v-if="field.type==='string'" v-model="ruleForm.plugin_config![field.key]" class="form-input" />
<input v-if="field.type==='password'" type="password" v-model="ruleForm.plugin_config![field.key]" class="form-input" />
<label v-if="field.type==='bool'" class="form-toggle">
<input type="checkbox" :checked="ruleForm.plugin_config![field.key]==='true'" @change="(e: Event) => ruleForm.plugin_config![field.key] = String((e.target as HTMLInputElement).checked)" />
<span>启用</span>
</label>
</div>
</template>
<template #footer> <template #footer>
<button class="glass-btn" @click="showRuleModal = false">取消</button> <button class="glass-btn" @click="showRuleModal = false">取消</button>
<button class="glass-btn primary" @click="handleRuleSubmit">保存</button> <button class="glass-btn primary" @click="handleRuleSubmit">保存</button>
</template> </template>
</GlassModal> </GlassModal>
<!-- Config Modal -->
<GlassModal :show="showConfigModal" :title="`${configPluginName} 配置`" @close="showConfigModal = false">
<div v-if="configLoading" class="loading-state">加载中...</div>
<template v-else>
<div v-for="field in configSchema" :key="field.key" class="form-group">
<label class="form-label">{{ field.label }}</label>
<input v-if="field.type==='string'" v-model="configValues[field.key]" class="form-input" />
<input v-if="field.type==='password'" type="password" v-model="configValues[field.key]" class="form-input" />
<input v-if="field.type==='number'" type="number" :value="Number(configValues[field.key])" @input="(e: Event) => configValues[field.key] = (e.target as HTMLInputElement).value" class="form-input" />
<label v-if="field.type==='bool'" class="form-toggle">
<input type="checkbox" :checked="configValues[field.key]==='true'" @change="(e: Event) => configValues[field.key] = String((e.target as HTMLInputElement).checked)" />
<span>启用</span>
</label>
</div>
</template>
<template #footer>
<button class="glass-btn" @click="showConfigModal = false">取消</button>
<button class="glass-btn primary" @click="savePluginConfig">保存</button>
</template>
</GlassModal>
<!-- Rename Modal --> <!-- Rename Modal -->
<GlassModal :show="showRenameModal" title="重命名客户端" width="400px" @close="showRenameModal = false"> <GlassModal :show="showRenameModal" title="重命名客户端" width="400px" @close="showRenameModal = false">
@@ -1025,35 +767,6 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
<button class="glass-btn primary" @click="saveRename">保存</button> <button class="glass-btn primary" @click="saveRename">保存</button>
</template> </template>
</GlassModal> </GlassModal>
<!-- Store Modal -->
<GlassModal :show="showStoreModal" title="插件商店" width="600px" @close="showStoreModal = false">
<div v-if="storeLoading" class="loading-state">加载中...</div>
<div v-else class="store-grid">
<div v-for="plugin in storePlugins" :key="plugin.name" class="store-plugin-card">
<div class="store-plugin-header">
<span class="store-plugin-name">{{ plugin.name }}</span>
<GlassTag>v{{ plugin.version }}</GlassTag>
</div>
<p class="store-plugin-desc">{{ plugin.description }}</p>
<button class="glass-btn primary small full" @click="handleInstallStorePlugin(plugin)">
安装
</button>
</div>
</div>
</GlassModal>
<!-- Install Config Modal -->
<GlassModal :show="showInstallConfigModal" title="安装配置" width="400px" @close="showInstallConfigModal = false">
<div class="form-group">
<label class="form-label">远程端口</label>
<input v-model.number="installRemotePort" type="number" class="form-input" min="1" max="65535" />
</div>
<template #footer>
<button class="glass-btn" @click="showInstallConfigModal = false">取消</button>
<button class="glass-btn primary" @click="confirmInstallPlugin">确认安装</button>
</template>
</GlassModal>
</div> </div>
</template> </template>
@@ -1481,92 +1194,6 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
background: rgba(0, 186, 124, 0.15); background: rgba(0, 186, 124, 0.15);
} }
/* Plugins List */
.plugins-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.plugin-item {
background: var(--color-bg-elevated);
border-radius: 10px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.plugin-info {
display: flex;
align-items: center;
gap: 10px;
}
.plugin-name {
font-weight: 600;
color: var(--color-text-primary);
font-size: 14px;
}
.plugin-version {
font-size: 12px;
color: var(--color-text-muted);
}
.plugin-meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--color-text-secondary);
}
.plugin-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
/* Store Plugin Card */
.store-plugin-card {
background: var(--color-bg-elevated);
border-radius: 10px;
padding: 16px;
border: 1px solid var(--color-border-light);
}
.store-plugin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.store-plugin-name {
font-weight: 600;
color: var(--color-text-primary);
font-size: 14px;
}
.store-plugin-desc {
color: var(--color-text-secondary);
font-size: 12px;
margin: 0 0 12px 0;
line-height: 1.5;
}
/* Store Grid */
.store-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
@media (max-width: 500px) {
.store-grid { grid-template-columns: 1fr; }
}
/* Form Styles */ /* Form Styles */
.form-group { .form-group {
margin-bottom: 16px; margin-bottom: 16px;
@@ -1709,12 +1336,6 @@ const handleDeletePlugin = (plugin: ClientPlugin) => {
height: 14px; height: 14px;
} }
.plugin-icon {
width: 18px;
height: 18px;
color: var(--color-accent);
}
.settings-icon { .settings-icon {
width: 16px; width: 16px;
height: 16px; height: 16px;