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 运行中
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🚀 frpc-console
+
+ {{ 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