Files
GoTunnel/web/src/views/ClientView.vue
Flik 98a5525e6d
Some checks failed
Build Multi-Platform Binaries / build-frontend (push) Successful in 27s
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Successful in 1m24s
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Successful in 1m33s
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Successful in 1m18s
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Successful in 1m40s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Successful in 1m16s
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) Failing after 1m39s
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Successful in 1m49s
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Successful in 1m26s
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Successful in 1m53s
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Successful in 1m16s
feat(client): 添加服务端版本检查和客户端更新逻辑
- 引入 getVersionInfo API 和服务端版本获取功能
- 实现版本比较算法用于判断更新需求
- 添加服务端版本加载和目标版本计算逻辑
- 更新客户端版本显示为可更新目标版本
- 优化样式表移除不透明背景设置
- 调整进度条外观样式增强视觉效果
2026-01-23 08:40:54 +08:00

1645 lines
46 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ArrowBackOutline, CreateOutline, TrashOutline,
PushOutline, AddOutline, StorefrontOutline, DocumentTextOutline,
ExtensionPuzzleOutline, SettingsOutline, RefreshOutline
} from '@vicons/ionicons5'
import GlassModal from '../components/GlassModal.vue'
import GlassTag from '../components/GlassTag.vue'
import GlassSwitch from '../components/GlassSwitch.vue'
import { useToast } from '../composables/useToast'
import { useConfirm } from '../composables/useConfirm'
import {
getClient, updateClient, deleteClient, pushConfigToClient, disconnectClient, restartClient,
getClientPluginConfig, updateClientPluginConfig,
getStorePlugins, installStorePlugin, getRuleSchemas, startClientPlugin, restartClientPlugin, stopClientPlugin, deleteClientPlugin,
checkClientUpdate, applyClientUpdate, getClientSystemStats, getVersionInfo,
type UpdateInfo, type SystemStats
} from '../api'
import type { ProxyRule, ClientPlugin, ConfigField, StorePluginInfo, RuleSchemasMap } from '../types'
import LogViewer from '../components/LogViewer.vue'
import InlineLogPanel from '../components/InlineLogPanel.vue'
const route = useRoute()
const router = useRouter()
const message = useToast()
const dialog = useConfirm()
const clientId = route.params.id as string
// Data
const online = ref(false)
const lastPing = ref('')
const remoteAddr = ref('')
const nickname = ref('')
const rules = ref<ProxyRule[]>([])
const clientPlugins = ref<ClientPlugin[]>([])
const loading = ref(false)
const clientOs = ref('')
const clientArch = ref('')
const clientVersion = ref('')
// 客户端更新相关
const clientUpdate = ref<UpdateInfo | null>(null)
const updatingClient = ref(false)
const serverVersion = ref('')
// 系统状态相关
const systemStats = ref<SystemStats | null>(null)
const loadingStats = ref(false)
// 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)
const builtinTypes = [
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
{ label: 'HTTP', value: 'http' },
{ label: 'HTTPS', value: 'https' },
{ label: 'SOCKS5', value: 'socks5' },
{ label: 'WebSocket', value: 'websocket' }
]
// Modal Control for Rules
const showRuleModal = ref(false)
const ruleModalType = ref<'create' | 'edit'>('create')
// Default Rule Model
const defaultRule = {
name: '',
local_ip: '127.0.0.1',
local_port: 80,
remote_port: 0,
type: 'tcp',
enabled: true,
plugin_config: {} as Record<string, string>
}
const ruleForm = ref<ProxyRule>({ ...defaultRule })
// Helper: Check if type needs local addr
const needsLocalAddr = (type: string) => {
const schema = pluginRuleSchemas.value[type]
return schema?.needs_local_addr ?? true
}
const getExtraFields = (type: string): ConfigField[] => {
const schema = pluginRuleSchemas.value[type]
return schema?.extra_fields || []
}
// 加载服务端版本
const loadServerVersion = async () => {
try {
const { data } = await getVersionInfo()
serverVersion.value = data.version || ''
} catch (e) {
console.error('Failed to load server version', e)
}
}
// 版本比较函数:返回 -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
const compareVersions = (v1: string, v2: string): number => {
const normalize = (v: string) => v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0)
const parts1 = normalize(v1)
const parts2 = normalize(v2)
const len = Math.max(parts1.length, parts2.length)
for (let i = 0; i < len; i++) {
const p1 = parts1[i] || 0
const p2 = parts2[i] || 0
if (p1 < p2) return -1
if (p1 > p2) return 1
}
return 0
}
// 判断客户端是否需要更新
// 逻辑:如果客户端最新版>=服务端版本,则目标版本为服务端版本;否则为客户端最新版
const needsUpdate = (): boolean => {
if (!clientUpdate.value?.latest || !clientVersion.value) return false
const latestClientVer = clientUpdate.value.latest
const currentClientVer = clientVersion.value
const serverVer = serverVersion.value
// 确定目标版本
let targetVersion = latestClientVer
if (serverVer && compareVersions(latestClientVer, serverVer) >= 0) {
targetVersion = serverVer
}
// 比较当前客户端版本和目标版本
return compareVersions(currentClientVer, targetVersion) < 0
}
// 获取目标更新版本
const getTargetVersion = (): string => {
if (!clientUpdate.value?.latest) return ''
const latestClientVer = clientUpdate.value.latest
const serverVer = serverVersion.value
if (serverVer && compareVersions(latestClientVer, serverVer) >= 0) {
return serverVer
}
return latestClientVer
}
// Actions
const loadClient = async () => {
loading.value = true
try {
const { data } = await getClient(clientId)
online.value = data.online
lastPing.value = data.last_ping || ''
remoteAddr.value = data.remote_addr || ''
nickname.value = data.nickname || ''
rules.value = data.rules || []
clientPlugins.value = data.plugins || []
clientOs.value = data.os || ''
clientArch.value = data.arch || ''
clientVersion.value = data.version || ''
// 如果客户端在线且有平台信息,自动检测更新
if (data.online && data.os && data.arch) {
autoCheckClientUpdate()
loadSystemStats()
}
} catch (e) {
message.error('加载客户端信息失败')
console.error(e)
} finally {
loading.value = false
}
}
// 自动检测客户端更新(静默)
const autoCheckClientUpdate = async () => {
try {
const { data } = await checkClientUpdate(clientOs.value, clientArch.value)
clientUpdate.value = data
} catch (e) {
console.error('Auto check update failed', e)
}
}
// 加载系统状态
const loadSystemStats = async () => {
if (!online.value) return
loadingStats.value = true
try {
const { data } = await getClientSystemStats(clientId)
systemStats.value = data
} catch (e) {
console.error('Failed to load system stats', e)
} finally {
loadingStats.value = false
}
}
// 格式化字节大小
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 客户端更新
const handleApplyClientUpdate = () => {
if (!clientUpdate.value?.download_url) {
message.error('没有可用的下载链接')
return
}
dialog.warning({
title: '确认更新客户端',
content: `即将更新客户端到 ${clientUpdate.value.latest},更新后客户端将自动重启。确定要继续吗?`,
positiveText: '更新',
negativeText: '取消',
onPositiveClick: async () => {
updatingClient.value = true
try {
await applyClientUpdate(clientId, clientUpdate.value!.download_url)
message.success('更新命令已发送,客户端将自动重启')
clientUpdate.value = null
} catch (e: any) {
message.error(e.response?.data || '更新失败')
} finally {
updatingClient.value = false
}
}
})
}
// Client Rename
const showRenameModal = ref(false)
const renameValue = ref('')
const openRenameModal = () => {
renameValue.value = nickname.value
showRenameModal.value = true
}
const saveRename = async () => {
try {
await updateClient(clientId, {
id: clientId,
nickname: renameValue.value,
rules: rules.value
})
nickname.value = renameValue.value
showRenameModal.value = false
message.success('重命名成功')
} catch (e) {
message.error('重命名失败')
}
}
// Rule Management
const openCreateRule = () => {
ruleModalType.value = 'create'
ruleForm.value = { ...defaultRule, remote_port: 8080 }
showRuleModal.value = true
}
const openEditRule = (rule: ProxyRule) => {
if (rule.plugin_managed) return
ruleModalType.value = 'edit'
ruleForm.value = JSON.parse(JSON.stringify(rule))
showRuleModal.value = true
}
const handleDeleteRule = (rule: ProxyRule) => {
dialog.warning({
title: '确认删除',
content: `确定要删除规则 "${rule.name}" 吗?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
const newRules = rules.value.filter(r => r.name !== rule.name)
await saveRules(newRules)
}
})
}
const saveRules = async (newRules: ProxyRule[]) => {
try {
await updateClient(clientId, {
id: clientId,
nickname: nickname.value,
rules: newRules
})
rules.value = newRules
message.success('规则保存成功')
if (online.value) {
await pushConfigToClient(clientId)
message.success('配置已推送到客户端')
}
} catch (e: any) {
message.error('保存失败: ' + (e.response?.data || e.message))
await loadClient()
}
}
const handleRuleSubmit = async () => {
// Simple validation
if (!ruleForm.value.name) {
message.error('请输入规则名称')
return
}
if (!ruleForm.value.remote_port || ruleForm.value.remote_port < 1 || ruleForm.value.remote_port > 65535) {
message.error('请输入有效的远程端口 (1-65535)')
return
}
if (needsLocalAddr(ruleForm.value.type || 'tcp')) {
if (!ruleForm.value.local_ip) {
message.error('请输入本地IP')
return
}
if (!ruleForm.value.local_port || ruleForm.value.local_port < 1 || ruleForm.value.local_port > 65535) {
message.error('请输入有效的本地端口 (1-65535)')
return
}
}
let newRules = [...rules.value]
if (ruleModalType.value === 'create') {
if (newRules.some(r => r.name === ruleForm.value.name)) {
message.error('规则名称已存在')
return
}
newRules.push({ ...ruleForm.value })
} else {
const index = newRules.findIndex(r => r.name === ruleForm.value.name)
if (index > -1) {
newRules[index] = { ...ruleForm.value }
}
}
await saveRules(newRules)
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
const confirmDelete = () => {
dialog.warning({
title: '确认删除', content: '确定要删除此客户端吗?',
positiveText: '删除', negativeText: '取消',
onPositiveClick: async () => {
await deleteClient(clientId); router.push('/')
}
})
}
const disconnect = () => {
dialog.warning({
title: '确认断开', content: '确定要断开连接吗?',
positiveText: '断开', negativeText: '取消',
onPositiveClick: async () => {
await disconnectClient(clientId); loadClient()
}
})
}
const handleRestartClient = () => {
dialog.warning({
title: '确认重启', content: '确定要重启客户端吗?',
positiveText: '重启', negativeText: '取消',
onPositiveClick: async () => {
await restartClient(clientId); message.success('重启命令已发送'); setTimeout(loadClient, 3000)
}
})
}
// Lifecycle
const pollTimer = ref<number | null>(null)
onMounted(() => {
loadRuleSchemas()
loadServerVersion()
loadClient()
// 启动自动轮询,每 5 秒刷新一次
pollTimer.value = window.setInterval(() => {
loadClient()
}, 5000)
})
onUnmounted(() => {
if (pollTimer.value) {
clearInterval(pollTimer.value)
pollTimer.value = null
}
})
// Log Viewer
const showLogViewer = ref(false)
// Plugin Menu
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>
<template>
<div class="client-page">
<!-- Particles -->
<div class="particles">
<div class="particle particle-1"></div>
<div class="particle particle-2"></div>
<div class="particle particle-3"></div>
</div>
<div class="client-content">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<button class="back-btn" @click="router.push('/')">
<ArrowBackOutline class="btn-icon-lg" />
</button>
<h1 class="page-title">{{ nickname || clientId }}</h1>
<button class="edit-btn" @click="openRenameModal">
<CreateOutline class="btn-icon" />
</button>
<span class="status-tag" :class="{ online }">
{{ online ? '在线' : '离线' }}
</span>
</div>
<div class="header-actions">
<button v-if="online" class="glass-btn primary" @click="pushConfigToClient(clientId).then(() => message.success('已推送'))">
<PushOutline class="btn-icon" />
<span>推送配置</span>
</button>
<button class="glass-btn" @click="showLogViewer=true">
<DocumentTextOutline class="btn-icon" />
<span>日志</span>
</button>
<button class="glass-btn danger" @click="confirmDelete">
<TrashOutline class="btn-icon" />
<span>删除</span>
</button>
</div>
</div>
<!-- Main Grid -->
<div class="main-grid">
<!-- Left Column -->
<div class="left-column">
<!-- Status Card -->
<div class="glass-card">
<div class="card-header">
<h3>客户端状态</h3>
<!-- Heartbeat indicator -->
<div class="heartbeat-indicator" :class="{ online: online, offline: !online }">
<span class="heartbeat-dot"></span>
</div>
</div>
<div class="card-body">
<div class="stat-item">
<span class="stat-label">连接 ID</span>
<span class="stat-value mono">{{ clientId }}</span>
</div>
<div class="stat-item">
<span class="stat-label">远程 IP</span>
<span class="stat-value">{{ remoteAddr || '-' }}</span>
</div>
<div class="stat-item">
<span class="stat-label">客户端版本</span>
<span class="stat-value">
{{ clientVersion || '-' }}
<span v-if="needsUpdate()" class="update-badge" @click="handleApplyClientUpdate">
可更新 {{ getTargetVersion() }}
</span>
<span v-else-if="clientVersion" class="latest-badge">
最新版本
</span>
</span>
</div>
<div class="stat-item">
<span class="stat-label">最后心跳</span>
<span class="stat-value">{{ lastPing ? new Date(lastPing).toLocaleTimeString() : '-' }}</span>
</div>
</div>
<div class="card-actions">
<button class="glass-btn warning small" @click="disconnect" :disabled="!online">断开连接</button>
<button class="glass-btn danger small" @click="handleRestartClient" :disabled="!online">重启客户端</button>
</div>
</div>
<!-- Stats Card -->
<div class="glass-card">
<div class="card-header">
<h3>统计</h3>
</div>
<div class="card-body stats-row">
<div class="mini-stat">
<span class="mini-stat-value">{{ rules.length }}</span>
<span class="mini-stat-label">规则数</span>
</div>
<div class="mini-stat">
<span class="mini-stat-value">{{ clientPlugins.length }}</span>
<span class="mini-stat-label">插件数</span>
</div>
</div>
</div>
<!-- System Stats Card -->
<div class="glass-card" v-if="online">
<div class="card-header">
<h3>系统状态</h3>
<button class="glass-btn tiny" :disabled="loadingStats" @click="loadSystemStats">
<RefreshOutline class="btn-icon-sm" />
刷新
</button>
</div>
<div class="card-body system-stats-body">
<Transition name="fade-slide" mode="out-in">
<div v-if="!systemStats" class="empty-hint" key="empty">
{{ loadingStats ? '加载中...' : '点击刷新获取状态' }}
</div>
<div v-else class="system-stats-content" key="stats">
<div class="system-stat-item">
<span class="system-stat-label">CPU</span>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: systemStats.cpu_usage + '%' }"></div>
</div>
<span class="system-stat-value">{{ systemStats.cpu_usage.toFixed(1) }}%</span>
</div>
<div class="system-stat-item">
<span class="system-stat-label">内存</span>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: systemStats.memory_usage + '%' }"></div>
</div>
<span class="system-stat-value">{{ systemStats.memory_usage.toFixed(1) }}%</span>
</div>
<div class="system-stat-detail">
{{ formatBytes(systemStats.memory_used) }} / {{ formatBytes(systemStats.memory_total) }}
</div>
<div class="system-stat-item">
<span class="system-stat-label">磁盘</span>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: systemStats.disk_usage + '%' }"></div>
</div>
<span class="system-stat-value">{{ systemStats.disk_usage.toFixed(1) }}%</span>
</div>
<div class="system-stat-detail">
{{ formatBytes(systemStats.disk_used) }} / {{ formatBytes(systemStats.disk_total) }}
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- Right Column -->
<div class="right-column">
<!-- Rules Card -->
<div class="glass-card">
<div class="card-header">
<h3>代理规则</h3>
<button class="glass-btn primary small" @click="openCreateRule">
<AddOutline class="btn-icon-sm" />
添加规则
</button>
</div>
<div class="card-body">
<div v-if="rules.length === 0" class="empty-state">
<p>暂无代理规则</p>
</div>
<div v-else class="rules-table">
<div class="table-header">
<span>名称</span>
<span>类型</span>
<span>映射</span>
<span>状态</span>
<span>操作</span>
</div>
<div v-for="rule in rules" :key="rule.name" class="table-row">
<span class="rule-name">{{ rule.name }}</span>
<span><GlassTag :type="rule.type==='websocket'?'info':'default'">{{ (rule.type || 'tcp').toUpperCase() }}</GlassTag></span>
<span class="rule-mapping">
{{ needsLocalAddr(rule.type||'tcp') ? `${rule.local_ip}:${rule.local_port}` : '-' }}
:{{ rule.remote_port }}
</span>
<span>
<GlassSwitch :model-value="rule.enabled !== false" @update:model-value="(v: boolean) => { rule.enabled = v; saveRules(rules) }" size="small" />
</span>
<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 danger" @click="handleDeleteRule(rule)">删除</button>
</template>
</span>
</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 -->
<div class="glass-card">
<InlineLogPanel :client-id="clientId" />
</div>
</div>
</div>
</div>
<!-- Rule Modal -->
<GlassModal :show="showRuleModal" :title="ruleModalType==='create'?'添加规则':'编辑规则'" @close="showRuleModal = false">
<div class="form-group">
<label class="form-label">名称</label>
<input v-model="ruleForm.name" class="form-input" placeholder="请输入规则名称" :disabled="ruleModalType==='edit'" />
</div>
<div class="form-group">
<label class="form-label">类型</label>
<select v-model="ruleForm.type" class="form-select">
<option v-for="t in builtinTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
<template v-if="needsLocalAddr(ruleForm.type || 'tcp')">
<div class="form-group">
<label class="form-label">本地IP</label>
<input v-model="ruleForm.local_ip" class="form-input" placeholder="127.0.0.1" />
</div>
<div class="form-group">
<label class="form-label">本地端口</label>
<input v-model.number="ruleForm.local_port" type="number" class="form-input" min="1" max="65535" />
</div>
</template>
<div class="form-group">
<label class="form-label">远程端口</label>
<input v-model.number="ruleForm.remote_port" type="number" class="form-input" min="1" max="65535" />
</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>
<button class="glass-btn" @click="showRuleModal = false">取消</button>
<button class="glass-btn primary" @click="handleRuleSubmit">保存</button>
</template>
</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 -->
<GlassModal :show="showRenameModal" title="重命名客户端" width="400px" @close="showRenameModal = false">
<div class="form-group">
<label class="form-label">新名称</label>
<input v-model="renameValue" class="form-input" placeholder="请输入新名称" />
</div>
<template #footer>
<button class="glass-btn" @click="showRenameModal = false">取消</button>
<button class="glass-btn primary" @click="saveRename">保存</button>
</template>
</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>
<LogViewer :visible="showLogViewer" @close="showLogViewer = false" :client-id="clientId" />
</div>
</template>
<style scoped>
.client-page {
min-height: calc(100vh - 108px);
background: transparent;
position: relative;
overflow: hidden;
padding: 32px;
}
/* Particles */
.particles {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
.particle {
position: absolute;
border-radius: 50%;
opacity: 0.15;
filter: blur(60px);
animation: float 20s ease-in-out infinite;
}
.particle-1 {
width: 350px;
height: 350px;
background: var(--color-accent);
top: -80px;
right: -80px;
}
.particle-2 {
width: 280px;
height: 280px;
background: #8b5cf6;
bottom: -40px;
left: -40px;
animation-delay: -5s;
}
.particle-3 {
width: 220px;
height: 220px;
background: var(--color-success);
top: 40%;
left: 30%;
animation-delay: -10s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -30px) scale(1.05); }
50% { transform: translate(-20px, 20px) scale(0.95); }
75% { transform: translate(-30px, -20px) scale(1.02); }
}
.client-content {
position: relative;
z-index: 10;
max-width: 1400px;
margin: 0 auto;
}
/* Page Header */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.back-btn, .edit-btn {
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 8px;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
}
.back-btn:hover, .edit-btn:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.page-title {
font-size: 24px;
font-weight: 700;
color: var(--color-text-primary);
margin: 0;
}
.status-tag {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
background: rgba(244, 33, 46, 0.15);
color: var(--color-error);
}
.status-tag.online {
background: rgba(0, 186, 124, 0.15);
color: var(--color-success);
}
.header-actions {
display: flex;
gap: 8px;
}
/* Glass Button */
.glass-btn {
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 8px 16px;
color: var(--color-text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.glass-btn:hover:not(:disabled) {
background: var(--color-bg-elevated);
}
.glass-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.glass-btn.primary {
background: var(--color-accent);
border: none;
}
.glass-btn.primary:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.glass-btn.danger {
background: rgba(244, 33, 46, 0.15);
border-color: rgba(244, 33, 46, 0.3);
color: var(--color-error);
}
.glass-btn.warning {
background: rgba(247, 147, 26, 0.15);
border-color: rgba(247, 147, 26, 0.3);
color: var(--color-warning);
}
.glass-btn.small { padding: 6px 12px; font-size: 12px; }
.glass-btn.tiny { padding: 4px 8px; font-size: 11px; }
.glass-btn.full { width: 100%; justify-content: center; }
/* Main Grid */
.main-grid {
display: grid;
grid-template-columns: 300px 1fr;
gap: 24px;
align-items: start;
}
@media (max-width: 900px) {
.main-grid { grid-template-columns: 1fr; }
}
.left-column, .right-column {
display: flex;
flex-direction: column;
gap: 20px;
}
/* Glass Card */
.glass-card {
background: var(--color-bg-tertiary);
border-radius: 12px;
border: 1px solid var(--color-border);
overflow: hidden;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--color-border-light);
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
}
/* Heartbeat Indicator */
.heartbeat-indicator {
position: relative;
}
.heartbeat-dot {
display: block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--color-error);
}
.heartbeat-indicator.online .heartbeat-dot {
background: var(--color-success);
animation: heartbeat-pulse 2s ease-in-out infinite;
}
.heartbeat-indicator.offline .heartbeat-dot {
background: var(--color-error);
animation: none;
}
@keyframes heartbeat-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(0, 186, 124, 0.5);
transform: scale(1);
}
50% {
box-shadow: 0 0 0 6px rgba(0, 186, 124, 0);
transform: scale(1.1);
}
}
.card-body { padding: 20px; }
.card-actions {
padding: 16px 20px;
border-top: 1px solid var(--color-border-light);
display: flex;
gap: 8px;
}
/* Stat Items */
.stat-item {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--color-border-light);
}
.stat-item:last-child { border-bottom: none; }
.stat-label {
color: var(--color-text-secondary);
font-size: 13px;
}
.stat-value {
color: var(--color-text-primary);
font-size: 13px;
}
.stat-value.mono {
font-family: monospace;
font-size: 12px;
}
.update-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
font-size: 11px;
background: rgba(247, 147, 26, 0.15);
color: var(--color-warning);
border-radius: 10px;
cursor: pointer;
transition: all 0.15s;
}
.update-badge:hover {
background: rgba(247, 147, 26, 0.25);
}
.latest-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
font-size: 11px;
background: rgba(0, 186, 124, 0.15);
color: var(--color-success);
border-radius: 10px;
}
/* Mini Stats */
.stats-row {
display: flex;
justify-content: space-around;
}
.mini-stat {
text-align: center;
}
.mini-stat-value {
display: block;
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
}
.mini-stat-label {
font-size: 12px;
color: var(--color-text-muted);
}
/* Update Card */
.platform-info {
font-size: 12px;
color: var(--color-text-muted);
margin-bottom: 8px;
}
.empty-hint {
color: var(--color-text-muted);
font-size: 13px;
text-align: center;
padding: 16px 0;
}
.update-available p {
margin: 0 0 8px 0;
color: var(--color-success);
font-size: 13px;
}
/* Rules Table */
.empty-state {
text-align: center;
padding: 32px;
color: var(--color-text-muted);
}
.rules-table {
overflow-x: auto;
}
.table-header, .table-row {
display: grid;
grid-template-columns: 1fr 80px 1.5fr 60px 100px;
gap: 12px;
padding: 10px 0;
align-items: center;
}
.table-header {
border-bottom: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: 12px;
font-weight: 500;
}
.table-row {
border-bottom: 1px solid var(--color-border-light);
color: var(--color-text-secondary);
font-size: 13px;
}
.rule-name { font-weight: 500; color: var(--color-text-primary); }
.rule-mapping { font-family: monospace; font-size: 12px; }
.rule-actions { display: flex; gap: 6px; justify-content: flex-end; }
/* Icon Button */
.icon-btn {
background: var(--color-bg-elevated);
border: none;
border-radius: 6px;
padding: 4px 10px;
color: var(--color-text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.icon-btn:hover:not(:disabled) {
background: var(--color-border);
color: var(--color-text-primary);
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon-btn.danger {
color: var(--color-error);
}
.icon-btn.danger:hover:not(:disabled) {
background: rgba(244, 33, 46, 0.15);
}
.icon-btn.success {
color: var(--color-success);
}
.icon-btn.success:hover:not(:disabled) {
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-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 13px;
color: var(--color-text-secondary);
margin-bottom: 6px;
}
.form-input {
width: 100%;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 10px 12px;
color: var(--color-text-primary);
font-size: 14px;
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.form-input:focus {
border-color: var(--color-accent);
}
.form-input::placeholder {
color: var(--color-text-muted);
}
.form-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-select {
width: 100%;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 10px 12px;
color: var(--color-text-primary);
font-size: 14px;
outline: none;
cursor: pointer;
}
.form-select option {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.form-toggle {
display: flex;
align-items: center;
gap: 8px;
color: var(--color-text-secondary);
font-size: 13px;
cursor: pointer;
}
.form-toggle input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--color-accent);
}
.loading-state {
text-align: center;
padding: 32px;
color: var(--color-text-muted);
}
/* Dropdown Menu */
.dropdown-wrapper {
position: relative;
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 4px;
min-width: 100px;
z-index: 100;
}
.dropdown-menu button {
display: block;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
color: var(--color-text-secondary);
font-size: 13px;
text-align: left;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s;
}
.dropdown-menu button:hover:not(:disabled) {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.dropdown-menu button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dropdown-menu button.danger {
color: var(--color-error);
}
.dropdown-menu button.danger:hover:not(:disabled) {
background: rgba(244, 33, 46, 0.15);
}
/* Icon styles */
.btn-icon {
width: 14px;
height: 14px;
}
.btn-icon-lg {
width: 20px;
height: 20px;
}
.btn-icon-sm {
width: 14px;
height: 14px;
}
.plugin-icon {
width: 18px;
height: 18px;
color: var(--color-accent);
}
.settings-icon {
width: 16px;
height: 16px;
}
/* System Stats Transition */
.system-stats-body {
overflow: hidden;
}
.system-stats-content {
display: flex;
flex-direction: column;
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(10px);
}
/* System Stats */
.system-stat-item {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.system-stat-label {
width: 40px;
font-size: 12px;
color: var(--color-text-secondary);
}
.progress-bar {
flex: 1;
height: 8px;
background: var(--color-border);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-accent);
border-radius: 4px;
transition: width 0.3s ease;
}
.system-stat-value {
width: 50px;
text-align: right;
font-size: 12px;
color: var(--color-text-primary);
font-family: monospace;
}
.system-stat-detail {
font-size: 11px;
color: var(--color-text-muted);
text-align: right;
margin-bottom: 12px;
margin-top: -4px;
}
</style>