From 8acbfa0f6a579588bf96e6b83e3110d71b1daa3b Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Thu, 23 Jul 2026 18:21:59 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E6=AC=A1=E5=BC=95=E5=85=A5=E5=85=A8?= =?UTF-8?q?=E6=96=B0WebUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frpc.log | 3 + static/app.js | 390 +++++++++++++++++++++++ static/index.html | 251 ++++++++++++++- static/style.css | 776 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1407 insertions(+), 13 deletions(-) create mode 100644 frpc.log diff --git a/frpc.log b/frpc.log new file mode 100644 index 0000000..59767d1 --- /dev/null +++ b/frpc.log @@ -0,0 +1,3 @@ +2026-07-23 18:16:48.909 [I] [sub/root.go:201] start frpc service for config file [./frpc.toml] with aggregated configuration +2026-07-23 18:16:48.919 [I] [client/service.go:308] try to connect to server... +2026-07-23 18:16:49.115 [I] [client/service.go:328] [bb35e22da9644b61] login to server success, get run id [bb35e22da9644b61] diff --git a/static/app.js b/static/app.js index e69de29..ac5e0bd 100644 --- a/static/app.js +++ b/static/app.js @@ -0,0 +1,390 @@ +const { createApp, ref, reactive, computed, onMounted } = Vue; + +const app = createApp({ + setup() { + // ===== 登录态 ===== + const loggedIn = ref(false); + const loading = ref(false); + const loginError = ref(''); + const loginForm = reactive({ + username: '', + password: '', + }); + + // ===== Token ===== + const token = ref(''); + + // ===== Tab ===== + const activeTab = ref('config'); + + // ===== 全局配置 ===== + const globalConfig = reactive({ + serverAddr: 'frp.whitetop.xyz', + serverPort: 9358, + token: 'Lxh10020328', + logLevel: 'info', + logMaxDays: 3, + tcpMux: true, + tcpMuxKeepalive: 30, + heartbeatInterval: 15, + heartbeatTimeout: 70, + poolCount: 8, + }); + const showDetail = ref(false); + + // ===== 隧道列表 ===== + const proxies = ref([]); + const searchKeyword = ref(''); + + // ===== frpc 状态 ===== + const frpcRunning = ref(false); + + // ===== 弹窗 ===== + const dialogVisible = ref(false); + const dialogMode = ref('add'); // 'add' | 'edit' + const dialogForm = reactive({ + id: null, + name: '', + type: 'tcp', + localIP: '', + localPort: 0, + remotePort: 0, + }); + + // ===== 计算属性 ===== + const filteredProxies = computed(() => { + if (!searchKeyword.value) return proxies.value; + const kw = searchKeyword.value.toLowerCase(); + return proxies.value.filter(p => + p.name.toLowerCase().includes(kw) || + p.localIP.includes(kw) || + String(p.remotePort).includes(kw) + ); + }); + + // ===== API 基础 ===== + const apiHeaders = () => ({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + token.value, + }); + + const apiFetch = async (url, opts = {}) => { + const res = await fetch(url, { + ...opts, + headers: { ...apiHeaders(), ...opts.headers }, + }); + return res.json(); + }; + + // ===== 登录 ===== + const doLogin = async () => { + if (!loginForm.username || !loginForm.password) { + loginError.value = '请输入用户名和密码'; + return; + } + loading.value = true; + loginError.value = ''; + try { + const res = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(loginForm), + }); + const data = await res.json(); + if (data.code === 0) { + token.value = data.data.token; + loggedIn.value = true; + await loadAllData(); + } else { + loginError.value = data.msg || '登录失败'; + } + } catch (e) { + loginError.value = '网络错误,请重试'; + } + loading.value = false; + }; + + const doLogout = () => { + loggedIn.value = false; + token.value = ''; + loginForm.username = ''; + loginForm.password = ''; + }; + + // ===== 加载数据 ===== + const loadAllData = async () => { + await Promise.all([ + loadConfig(), + loadProxies(), + loadFrpcStatus(), + ]); + }; + + const loadConfig = async () => { + try { + const data = await apiFetch('/api/config'); + if (data.code === 0) { + Object.assign(globalConfig, data.data); + } + } catch (e) { + console.warn('加载配置失败', e); + } + }; + + const loadProxies = async () => { + try { + const data = await apiFetch('/api/proxies'); + if (data.code === 0) { + proxies.value = data.data || []; + } + } catch (e) { + console.warn('加载隧道列表失败', e); + } + }; + + const loadFrpcStatus = async () => { + try { + const data = await apiFetch('/api/frpc/status'); + if (data.code === 0) { + frpcRunning.value = data.data.running || false; + } + } catch (e) { + console.warn('获取 frpc 状态失败', e); + } + }; + + // ===== 保存配置 ===== + const saveConfig = async () => { + try { + const data = await apiFetch('/api/config', { + method: 'PUT', + body: JSON.stringify(globalConfig), + }); + if (data.code === 0) { + await loadFrpcStatus(); + alert('配置已更新并热加载'); + } else { + alert('保存失败: ' + data.msg); + } + } catch (e) { + alert('网络错误'); + } + }; + + // ===== 隧道操作 ===== + const toggleProxy = async (p) => { + try { + const data = await apiFetch('/api/proxy/' + p.id, { + method: 'PUT', + body: JSON.stringify(p), + }); + if (data.code !== 0) { + alert('更新失败: ' + data.msg); + await loadProxies(); + } else { + await loadFrpcStatus(); + } + } catch (e) { + alert('网络错误'); + await loadProxies(); + } + }; + + const deleteProxy = async (id) => { + if (!confirm('确定要删除这条隧道吗?')) return; + try { + const data = await apiFetch('/api/proxy/' + id, { + method: 'DELETE', + }); + if (data.code === 0) { + await loadProxies(); + await loadFrpcStatus(); + } else { + alert('删除失败: ' + data.msg); + } + } catch (e) { + alert('网络错误'); + } + }; + + // ===== 弹窗 ===== + const openAddDialog = () => { + dialogMode.value = 'add'; + dialogForm.id = null; + dialogForm.name = ''; + dialogForm.type = 'tcp'; + dialogForm.localIP = ''; + dialogForm.localPort = 0; + dialogForm.remotePort = 0; + dialogVisible.value = true; + }; + + const openEditDialog = (p) => { + dialogMode.value = 'edit'; + Object.assign(dialogForm, p); + dialogVisible.value = true; + }; + + const confirmDialog = async () => { + if (!dialogForm.name || !dialogForm.localIP || !dialogForm.localPort || !dialogForm.remotePort) { + alert('请填写完整信息'); + return; + } + const url = dialogMode.value === 'add' ? '/api/proxy' : '/api/proxy/' + dialogForm.id; + const method = dialogMode.value === 'add' ? 'POST' : 'PUT'; + try { + const data = await apiFetch(url, { + method: method, + body: JSON.stringify(dialogForm), + }); + if (data.code === 0) { + dialogVisible.value = false; + await loadProxies(); + await loadFrpcStatus(); + } else { + alert('操作失败: ' + data.msg); + } + } catch (e) { + alert('网络错误'); + } + }; + + // ===== 导入 TOML ===== + const triggerImport = () => { + document.getElementById('tomlFileInput').click(); + }; + + const handleImport = async (e) => { + const file = e.target.files[0]; + if (!file) return; + try { + const text = await file.text(); + const parsed = TOML.parse(text); + if (!parsed.proxies || !Array.isArray(parsed.proxies)) { + alert('无效的 TOML 文件:缺少 proxies 数组'); + return; + } + // 过滤出有效的隧道条目 + const list = parsed.proxies.filter(p => p.name && p.localIP && p.localPort && p.remotePort); + if (list.length === 0) { + alert('未解析到有效的隧道条目'); + return; + } + if (!confirm(`解析到 ${list.length} 条隧道,将覆盖当前配置,确认导入?`)) return; + + // 批量导入:先清空,再逐条插入(后端未提供批量接口,用循环) + // 简单做法:先删除所有,再逐个创建 + for (const p of proxies.value) { + await apiFetch('/api/proxy/' + p.id, { method: 'DELETE' }); + } + for (const p of list) { + await apiFetch('/api/proxy', { + method: 'POST', + body: JSON.stringify({ + name: p.name, + type: p.type || 'tcp', + localIP: p.localIP, + localPort: p.localPort, + remotePort: p.remotePort, + enabled: p.enabled !== undefined ? p.enabled : true, + }), + }); + } + await loadProxies(); + await loadFrpcStatus(); + alert('导入成功!'); + } catch (err) { + alert('解析 TOML 失败: ' + err.message); + } + e.target.value = ''; + }; + + // ===== 导出 TOML ===== + const exportToml = async () => { + try { + const configData = await apiFetch('/api/config'); + if (configData.code !== 0) { + alert('获取配置失败'); + return; + } + const proxyData = await apiFetch('/api/proxies'); + if (proxyData.code !== 0) { + alert('获取隧道列表失败'); + return; + } + const cfg = configData.data; + const proxyList = proxyData.data || []; + + const tomlObj = { + serverAddr: cfg.serverAddr, + serverPort: cfg.serverPort, + auth: { token: cfg.token }, + log: { to: './frpc.log', level: cfg.logLevel, maxDays: cfg.logMaxDays }, + transport: { + tcpMux: cfg.tcpMux, + tcpMuxKeepaliveInterval: cfg.tcpMuxKeepalive, + heartbeatInterval: cfg.heartbeatInterval, + heartbeatTimeout: cfg.heartbeatTimeout, + poolCount: cfg.poolCount, + }, + proxies: proxyList.map(p => ({ + name: p.name, + type: p.type, + localIP: p.localIP, + localPort: p.localPort, + remotePort: p.remotePort, + })), + }; + const tomlStr = TOML.stringify(tomlObj); + const blob = new Blob([tomlStr], { type: 'text/plain' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'frpc.toml'; + a.click(); + URL.revokeObjectURL(a.href); + } catch (e) { + alert('导出失败: ' + e.message); + } + }; + + // ===== 键盘快捷键 ===== + onMounted(() => { + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && dialogVisible.value) { + dialogVisible.value = false; + } + }); + }); + + // ===== 返回所有 ===== + return { + loggedIn, + loading, + loginError, + loginForm, + doLogin, + doLogout, + activeTab, + globalConfig, + showDetail, + saveConfig, + proxies, + searchKeyword, + filteredProxies, + toggleProxy, + deleteProxy, + frpcRunning, + dialogVisible, + dialogMode, + dialogForm, + openAddDialog, + openEditDialog, + confirmDialog, + triggerImport, + handleImport, + exportToml, + }; + }, +}); + +app.mount('#app'); \ No newline at end of file diff --git a/static/index.html b/static/index.html index b49a20b..51e4817 100644 --- a/static/index.html +++ b/static/index.html @@ -1,22 +1,247 @@ - - + + frpc-console - + + + + + + + + + -
-

🚀 frpc-console

-

后端已启动,正在等待前端接入...

-
✅ frpc 运行中
+ +
+ +
+ + +
+ + + + +
+ +
+
+ + + {{ frpcRunning ? 'frpc 运行中' : 'frpc 已停止' }} +
+
+ {{ loginForm.username }} + +
+
+ + +
+ + 全局配置 + + + 隧道列表 + +
+ + +
+ +
+
+
+
TCP Multiplexing
+
+ + + {{ globalConfig.tcpMux ? '已启用' : '已禁用' }} + +
+
+
+
连接池大小
+
+ {{ globalConfig.poolCount || 8 }} + +
+
+
+
心跳间隔 / 超时
+
+ {{ globalConfig.heartbeatInterval || 15 }}s + / + {{ globalConfig.heartbeatTimeout || 70 }}s +
+
+
+ +
+ {{ showDetail ? '收起全部配置 ▲' : '展开全部配置 ▼' }} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+
+ + + +
+
+ +
+
+ + +
+
+
+
+ + {{ p.name }} + {{ p.localIP }}:{{ p.localPort }} + {{ p.type }} +
+
+ {{ p.remotePort }} + + +
+
+ +
+
+ 暂无隧道,点击「新增隧道」添加 +
+
+
+
+
+
+ + +
+
+

{{ dialogMode === 'add' ? '新增隧道' : '编辑隧道' }}

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+ + + \ No newline at end of file diff --git a/static/style.css b/static/style.css index e69de29..33065dc 100644 --- a/static/style.css +++ b/static/style.css @@ -0,0 +1,776 @@ +/* ===== 全局重置 ===== */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; + background: #0a0a0f; + height: 100vh; + overflow: hidden; + color: #e0e0e0; +} + +/* ===== 背景磨砂层 ===== */ +.app-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: + radial-gradient(ellipse at 20% 50%, rgba(30, 60, 120, 0.3) 0%, transparent 60%), + radial-gradient(ellipse at 80% 50%, rgba(80, 30, 120, 0.2) 0%, transparent 60%), + #0a0a0f; + z-index: 0; +} + +/* ===== 主容器 ===== */ +.app-container { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1; + background: rgba(255, 255, 255, 0.04); + backdrop-filter: blur(40px) saturate(1.2); + -webkit-backdrop-filter: blur(40px) saturate(1.2); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 32px; + box-shadow: 0 40px 80px rgba(0, 0, 0, 0.8), inset 0 1px 0 rgba(255, 255, 255, 0.05); + transition: all 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94); + overflow: hidden; +} + +/* 登录态尺寸:800x650 */ +.app-container:not(.is-logged-in) { + width: 800px; + height: 650px; +} + +/* 登录后尺寸:1600x900(响应式) */ +.app-container.is-logged-in { + width: min(1600px, 92vw); + height: min(900px, 88vh); +} + +/* ===== 登录卡片 ===== */ +.login-card { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 48px 64px; +} + +.login-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 40px; +} + +.login-icon { + font-size: 36px; +} + +.login-header h1 { + font-size: 28px; + font-weight: 300; + letter-spacing: 6px; + color: rgba(255, 255, 255, 0.85); + text-transform: lowercase; +} + +.login-form { + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.input-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.input-group label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 2px; + color: rgba(255, 255, 255, 0.3); +} + +.input-group input { + height: 56px; + padding: 0 20px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + color: #fff; + font-size: 16px; + transition: border-color 0.3s; + outline: none; +} + +.input-group input:focus { + border-color: rgba(99, 226, 183, 0.4); +} + +.input-group input::placeholder { + color: rgba(255, 255, 255, 0.2); +} + +.login-btn { + height: 56px; + margin-top: 12px; + background: linear-gradient(135deg, #63e2b7, #3b9e8a); + border: none; + border-radius: 12px; + font-size: 16px; + font-weight: 600; + color: #0a0a0f; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; +} + +.login-btn:hover:not(:disabled) { + transform: scale(1.02); + box-shadow: 0 8px 24px rgba(99, 226, 183, 0.3); +} + +.login-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.login-error { + color: #f87171; + font-size: 14px; + text-align: center; + margin-top: 4px; + min-height: 24px; +} + +/* ===== 主界面 ===== */ +.main-panel { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + padding: 20px 28px; +} + +/* 顶部导航 */ +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + flex-shrink: 0; +} + +.top-left { + display: flex; + align-items: center; + gap: 16px; +} + +.logo { + font-size: 18px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #444; + transition: background 0.3s; +} + +.status-dot.active { + background: #63e2b7; + box-shadow: 0 0 12px rgba(99, 226, 183, 0.4); +} + +.status-text { + font-size: 13px; + color: rgba(255, 255, 255, 0.4); +} + +.top-right { + display: flex; + align-items: center; + gap: 16px; +} + +.user-name { + font-size: 13px; + color: rgba(255, 255, 255, 0.6); +} + +.logout-btn { + padding: 6px 16px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + color: rgba(255, 255, 255, 0.6); + font-size: 12px; + cursor: pointer; + transition: background 0.2s; +} + +.logout-btn:hover { + background: rgba(255, 70, 70, 0.15); + border-color: rgba(255, 70, 70, 0.3); + color: #f87171; +} + +/* Tab 栏 */ +.tab-bar { + display: flex; + gap: 32px; + padding: 14px 0 12px; + flex-shrink: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.tab-item { + font-size: 14px; + color: rgba(255, 255, 255, 0.35); + cursor: pointer; + padding: 4px 0; + transition: color 0.3s, border-color 0.3s; + border-bottom: 2px solid transparent; +} + +.tab-item:hover { + color: rgba(255, 255, 255, 0.7); +} + +.tab-item.active { + color: #63e2b7; + border-bottom-color: #63e2b7; +} + +/* 内容区 */ +.content-area { + flex: 1; + overflow-y: auto; + padding-top: 16px; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.06) transparent; +} + +.content-area::-webkit-scrollbar { + width: 4px; +} + +.content-area::-webkit-scrollbar-track { + background: transparent; +} + +.content-area::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} + +.tab-content { + height: 100%; +} + +/* ===== 全局配置页 ===== */ +.config-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-bottom: 20px; +} + +.metric-card { + background: rgba(255, 255, 255, 0.03); + border-radius: 16px; + padding: 16px 20px; + border: 1px solid rgba(255, 255, 255, 0.04); +} + +.metric-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.3); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 8px; +} + +.metric-value { + display: flex; + align-items: center; + gap: 12px; +} + +.big-number { + font-size: 28px; + font-weight: 600; + color: #fff; +} + +.unit { + font-size: 14px; + color: rgba(255, 255, 255, 0.3); +} + +.digit { + font-family: 'JetBrains Mono', monospace; + font-size: 18px; + color: #fff; +} + +.sep { + color: rgba(255, 255, 255, 0.15); +} + +.status-text { + font-size: 13px; + color: rgba(255, 255, 255, 0.3); + transition: color 0.3s; +} + +.status-text.active { + color: #63e2b7; +} + +/* 开关 */ +.switch { + position: relative; + width: 44px; + height: 24px; + display: inline-block; + cursor: pointer; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.switch .slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.1); + border-radius: 24px; + transition: background 0.3s; +} + +.switch .slider::before { + content: ''; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background: #1a1a24; + border-radius: 50%; + transition: transform 0.3s; +} + +.switch input:checked+.slider { + background: #63e2b7; +} + +.switch input:checked+.slider::before { + transform: translateX(20px); + background: #0a0a0f; +} + +.switch.small { + width: 32px; + height: 18px; +} + +.switch.small .slider::before { + height: 12px; + width: 12px; + left: 3px; + bottom: 3px; +} + +.switch.small input:checked+.slider::before { + transform: translateX(14px); +} + +/* 展开详情 */ +.detail-collapse { + padding: 12px 0; + color: rgba(255, 255, 255, 0.3); + font-size: 13px; + cursor: pointer; + user-select: none; + transition: color 0.3s; +} + +.detail-collapse:hover { + color: rgba(255, 255, 255, 0.6); +} + +.detail-panel { + background: rgba(255, 255, 255, 0.02); + border-radius: 12px; + padding: 16px 20px; + border: 1px solid rgba(255, 255, 255, 0.04); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 24px; + margin-bottom: 16px; +} + +.detail-panel .form-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.detail-panel .form-row label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.25); +} + +.detail-panel .form-row input, +.detail-panel .form-row select { + padding: 8px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + color: #fff; + font-size: 14px; + outline: none; + transition: border-color 0.3s; +} + +.detail-panel .form-row input:focus, +.detail-panel .form-row select:focus { + border-color: rgba(99, 226, 183, 0.3); +} + +.save-btn { + grid-column: 1 / -1; + padding: 10px 24px; + background: linear-gradient(135deg, #63e2b7, #3b9e8a); + border: none; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + color: #0a0a0f; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + margin-top: 4px; +} + +.save-btn:hover { + transform: scale(1.01); + box-shadow: 0 4px 16px rgba(99, 226, 183, 0.25); +} + +/* ===== 隧道列表页 ===== */ +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 12px; +} + +.toolbar-left { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.toolbar-left button { + padding: 8px 18px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.03); + color: rgba(255, 255, 255, 0.7); + font-size: 13px; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +.toolbar-left button:hover { + background: rgba(255, 255, 255, 0.06); +} + +.btn-import:hover { + border-color: rgba(99, 226, 183, 0.3); + color: #63e2b7; +} +.btn-export:hover { + border-color: rgba(99, 150, 255, 0.3); + color: #6a9fff; +} +.btn-add { + background: rgba(99, 226, 183, 0.12) !important; + border-color: rgba(99, 226, 183, 0.2) !important; + color: #63e2b7 !important; +} +.btn-add:hover { + background: rgba(99, 226, 183, 0.2) !important; +} + +.toolbar-right .search-input { + padding: 8px 16px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + color: #fff; + font-size: 13px; + outline: none; + width: 200px; + transition: border-color 0.3s; +} + +.toolbar-right .search-input:focus { + border-color: rgba(99, 226, 183, 0.3); +} + +.toolbar-right .search-input::placeholder { + color: rgba(255, 255, 255, 0.2); +} + +/* 隧道卡片列表 */ +.proxy-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.proxy-card { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 14px; + padding: 12px 18px; + transition: border-color 0.3s; +} + +.proxy-card:hover { + border-color: rgba(255, 255, 255, 0.08); +} + +.proxy-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.proxy-left { + display: flex; + align-items: center; + gap: 12px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #333; + transition: background 0.3s; +} + +.status-dot.active { + background: #63e2b7; + box-shadow: 0 0 10px rgba(99, 226, 183, 0.3); +} + +.proxy-name { + font-size: 15px; + font-weight: 500; + color: #fff; +} + +.proxy-addr { + font-size: 13px; + color: rgba(255, 255, 255, 0.3); + font-family: 'JetBrains Mono', monospace; +} + +.proxy-tag { + font-size: 10px; + padding: 2px 10px; + background: rgba(255, 255, 255, 0.04); + border-radius: 10px; + color: rgba(255, 255, 255, 0.3); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.proxy-right { + display: flex; + align-items: center; + gap: 12px; +} + +.remote-port { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + color: rgba(255, 255, 255, 0.4); +} + +.icon-btn { + background: none; + border: none; + color: rgba(255, 255, 255, 0.2); + font-size: 16px; + cursor: pointer; + padding: 4px 6px; + transition: color 0.2s; + border-radius: 6px; +} + +.icon-btn:hover { + color: rgba(255, 255, 255, 0.7); + background: rgba(255, 255, 255, 0.04); +} + +.icon-btn.danger:hover { + color: #f87171; + background: rgba(248, 113, 113, 0.1); +} + +.proxy-footer { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.04); + display: flex; + align-items: center; + gap: 10px; +} + +.status-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.2); +} + +.empty-state { + padding: 48px 0; + text-align: center; + color: rgba(255, 255, 255, 0.15); + font-size: 14px; +} + +/* ===== 弹窗 ===== */ +.dialog-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(8px); + z-index: 10; + display: flex; + justify-content: center; + align-items: center; +} + +.dialog-card { + background: #1a1a24; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 20px; + padding: 28px 32px; + width: 480px; + max-width: 90vw; + box-shadow: 0 32px 64px rgba(0, 0, 0, 0.6); +} + +.dialog-card h3 { + font-size: 18px; + font-weight: 500; + color: #fff; + margin-bottom: 20px; +} + +.dialog-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.dialog-form .form-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dialog-form .form-row label { + font-size: 12px; + color: rgba(255, 255, 255, 0.3); +} + +.dialog-form .form-row input, +.dialog-form .form-row select { + padding: 8px 14px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + color: #fff; + font-size: 14px; + outline: none; + transition: border-color 0.3s; +} + +.dialog-form .form-row input:focus, +.dialog-form .form-row select:focus { + border-color: rgba(99, 226, 183, 0.3); +} + +.dialog-actions { + display: flex; + gap: 12px; + margin-top: 20px; + justify-content: flex-end; +} + +.dialog-actions button { + padding: 8px 24px; + border-radius: 10px; + border: none; + font-size: 14px; + cursor: pointer; + transition: background 0.2s, transform 0.2s; +} + +.btn-cancel { + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.5); +} + +.btn-cancel:hover { + background: rgba(255, 255, 255, 0.08); +} + +.btn-confirm { + background: linear-gradient(135deg, #63e2b7, #3b9e8a); + color: #0a0a0f; + font-weight: 600; +} + +.btn-confirm:hover { + transform: scale(1.02); + box-shadow: 0 4px 16px rgba(99, 226, 183, 0.2); +} \ No newline at end of file