diff --git a/web/static/css/style.css b/web/static/css/style.css index 29e3b1a0..21ee7ac6 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -8860,6 +8860,71 @@ header { border-radius: 10px; border: 1px solid var(--border-color); } +.webshell-terminal-sessions { + display: flex; + align-items: center; + gap: 2px; + padding: 0 8px; + height: 34px; + background: #0b0f14; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + overflow-x: auto; +} +.webshell-terminal-session { + display: inline-flex; + align-items: center; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.12); + border-bottom: none; + border-radius: 6px 6px 0 0; + height: 30px; + color: #c9d1d9; +} +.webshell-terminal-session.active { + background: #0d1117; + border-color: rgba(88, 166, 255, 0.45); + color: #e6edf3; +} +.webshell-terminal-session-main { + border: 0; + background: transparent; + color: inherit; + font-size: 12px; + height: 100%; + padding: 0 10px; + cursor: pointer; + white-space: nowrap; +} +.webshell-terminal-session-close { + border: 0; + background: transparent; + color: #8b949e; + width: 20px; + height: 100%; + cursor: pointer; + font-size: 12px; +} +.webshell-terminal-session-close:hover { + color: #f85149; + background: rgba(248, 81, 73, 0.08); +} +.webshell-terminal-session-add { + border: 1px solid rgba(255, 255, 255, 0.16); + border-bottom: none; + background: rgba(255, 255, 255, 0.03); + color: #8b949e; + height: 30px; + width: 28px; + border-radius: 6px 6px 0 0; + cursor: pointer; + font-size: 16px; + line-height: 1; + flex-shrink: 0; +} +.webshell-terminal-session-add:hover { + color: #e6edf3; + background: rgba(255, 255, 255, 0.08); +} .webshell-quick-label { font-size: 12px; font-weight: 500; @@ -8869,20 +8934,48 @@ header { .webshell-terminal-toolbar .btn-ghost { font-size: 12px; } +.webshell-terminal-status { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + border: 1px solid transparent; +} +.webshell-terminal-status.idle { + color: #166534; + background: rgba(34, 197, 94, 0.1); + border-color: rgba(34, 197, 94, 0.25); +} +.webshell-terminal-status.running { + color: #9a3412; + background: rgba(251, 146, 60, 0.14); + border-color: rgba(251, 146, 60, 0.28); +} #webshell-pane-terminal { flex-direction: column; } /* 仅外框圆角,内部不做额外装饰,避免挡住文字 */ -.webshell-terminal-container { +.webshell-terminal-shell { flex: 1; min-height: 360px; - padding: 0; + display: flex; + flex-direction: column; background: #0d1117; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} +.webshell-terminal-container { + flex: 1; + min-height: 0; + padding: 0; + background: transparent; + overflow: hidden; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; transform: translateZ(0); diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index c4534273..ad8c7b01 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -430,6 +430,13 @@ "testFailed": "Connectivity test failed", "testNoExpectedOutput": "Shell responded but expected output was not found. Check password and command parameter name.", "clearScreen": "Clear", + "copyTerminalLog": "Copy log", + "terminalIdle": "Idle", + "terminalRunning": "Running", + "terminalCopyOk": "Log copied", + "terminalCopyFail": "Copy failed", + "terminalNewWindow": "New terminal", + "terminalWindowPrefix": "Terminal", "running": "Running…", "waitFinish": "Please wait for the current command to finish", "newDir": "New directory", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index d18e5859..d63a1477 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -430,6 +430,13 @@ "testFailed": "连通性测试失败", "testNoExpectedOutput": "Shell 返回了响应但未得到预期输出,请检查连接密码与命令参数名", "clearScreen": "清屏", + "copyTerminalLog": "复制日志", + "terminalIdle": "空闲", + "terminalRunning": "执行中", + "terminalCopyOk": "日志已复制", + "terminalCopyFail": "复制失败", + "terminalNewWindow": "新终端", + "terminalWindowPrefix": "终端", "running": "执行中…", "waitFinish": "请等待当前命令执行完成", "newDir": "新建目录", diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index f14c199f..0a5ff5cc 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -14,6 +14,11 @@ let webshellTerminalResizeContainer = null; let webshellCurrentConn = null; let webshellLineBuffer = ''; let webshellRunning = false; +let webshellTerminalRunning = false; +let webshellTerminalLogsByConn = {}; +let webshellTerminalSessionsByConn = {}; +let webshellPersistLoadedByConn = {}; +let webshellPersistSaveTimersByConn = {}; // 按连接保存命令历史,用于上下键 let webshellHistoryByConn = {}; let webshellHistoryIndex = -1; @@ -146,6 +151,13 @@ function wsT(key) { 'webshell.testFailed': '连通性测试失败', 'webshell.testNoExpectedOutput': 'Shell 返回了响应但未得到预期输出,请检查连接密码与命令参数名', 'webshell.clearScreen': '清屏', + 'webshell.copyTerminalLog': '复制日志', + 'webshell.terminalIdle': '空闲', + 'webshell.terminalRunning': '执行中', + 'webshell.terminalCopyOk': '日志已复制', + 'webshell.terminalCopyFail': '复制失败', + 'webshell.terminalNewWindow': '新终端', + 'webshell.terminalWindowPrefix': '终端', 'webshell.running': '执行中…', 'webshell.waitFinish': '请等待当前命令执行完成', 'webshell.newDir': '新建目录', @@ -201,6 +213,10 @@ function bindWebshellClearOnce() { destroyWebshellTerminal(); webshellLineBuffer = ''; webshellHistoryIndex = -1; + if (webshellCurrentConn && webshellCurrentConn.id) { + var sid = getActiveWebshellTerminalSessionId(webshellCurrentConn.id); + clearWebshellTerminalLog(getWebshellTerminalSessionKey(webshellCurrentConn.id, sid)); + } initWebshellTerminal(webshellCurrentConn); } finally { setTimeout(function () { webshellClearInProgress = false; }, 100); @@ -341,6 +357,8 @@ function destroyWebshellTerminal() { webshellTerminalFitAddon = null; webshellLineBuffer = ''; webshellRunning = false; + webshellTerminalRunning = false; + setWebshellTerminalStatus(false); } // 渲染连接列表 @@ -543,6 +561,195 @@ function normalizeWebshellPath(path) { return p || '.'; } +function getWebshellTerminalSessionKey(connId, sessionId) { + if (!connId || !sessionId) return ''; + return String(connId) + '::' + String(sessionId); +} + +function normalizeWebshellTerminalSessions(raw) { + var state = raw && typeof raw === 'object' ? raw : {}; + var list = Array.isArray(state.sessions) ? state.sessions.slice() : []; + if (!list.length) { + list = [{ id: 't1', name: (wsT('webshell.terminalWindowPrefix') || '终端') + '1' }]; + } + list = list.map(function (s, i) { + var id = (s && s.id ? String(s.id) : ('t' + (i + 1))); + var name = (s && s.name ? String(s.name) : ((wsT('webshell.terminalWindowPrefix') || '终端') + (i + 1))); + return { id: id, name: name }; + }); + var activeId = state.activeId; + if (!activeId || !list.some(function (s) { return s.id === activeId; })) activeId = list[0].id; + return { sessions: list, activeId: activeId }; +} + +function getWebshellTerminalSessions(connId) { + if (!connId) return normalizeWebshellTerminalSessions(null); + if (webshellTerminalSessionsByConn[connId]) return webshellTerminalSessionsByConn[connId]; + var state = normalizeWebshellTerminalSessions(null); + webshellTerminalSessionsByConn[connId] = state; + return state; +} + +function saveWebshellTerminalSessions(connId, state) { + if (!connId || !state) return; + var normalized = normalizeWebshellTerminalSessions(state); + webshellTerminalSessionsByConn[connId] = normalized; + queueWebshellPersistStateSave(connId); +} + +function getActiveWebshellTerminalSessionId(connId) { + return getWebshellTerminalSessions(connId).activeId; +} + +function getWebshellTerminalLog(connId) { + if (!connId) return ''; + if (typeof webshellTerminalLogsByConn[connId] === 'string') return webshellTerminalLogsByConn[connId]; + webshellTerminalLogsByConn[connId] = ''; + return ''; +} + +function saveWebshellTerminalLog(connId, content) { + if (!connId) return; + var text = String(content || ''); + var maxLen = 50000; // keep recent terminal output only + if (text.length > maxLen) text = text.slice(text.length - maxLen); + webshellTerminalLogsByConn[connId] = text; +} + +function appendWebshellTerminalLog(connId, chunk) { + if (!connId || !chunk) return; + var current = getWebshellTerminalLog(connId); + saveWebshellTerminalLog(connId, current + String(chunk)); +} + +function clearWebshellTerminalLog(connId) { + if (!connId) return; + webshellTerminalLogsByConn[connId] = ''; +} + +function buildWebshellPersistState(connId) { + var dbState = getWebshellDbState({ id: connId }); + var terminalSessions = getWebshellTerminalSessions(connId); + return { + dbState: dbState || null, + terminalSessions: terminalSessions || null + }; +} + +function applyWebshellPersistState(connId, state) { + if (!connId || !state || typeof state !== 'object') return; + if (state.dbState && typeof state.dbState === 'object') { + var key = getWebshellDbStateStorageKey({ id: connId }); + webshellDbConfigByConn[key] = normalizeWebshellDbState(state.dbState); + } + if (state.terminalSessions && typeof state.terminalSessions === 'object') { + webshellTerminalSessionsByConn[connId] = normalizeWebshellTerminalSessions(state.terminalSessions); + } +} + +function queueWebshellPersistStateSave(connId) { + if (!connId || typeof apiFetch !== 'function') return; + if (webshellPersistSaveTimersByConn[connId]) clearTimeout(webshellPersistSaveTimersByConn[connId]); + webshellPersistSaveTimersByConn[connId] = setTimeout(function () { + delete webshellPersistSaveTimersByConn[connId]; + var payload = buildWebshellPersistState(connId); + apiFetch('/api/webshell/connections/' + encodeURIComponent(connId) + '/state', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: payload }) + }).catch(function () {}); + }, 500); +} + +function ensureWebshellPersistStateLoaded(conn) { + if (!conn || !conn.id || typeof apiFetch !== 'function') return Promise.resolve(); + if (webshellPersistLoadedByConn[conn.id]) return Promise.resolve(); + return apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id) + '/state', { method: 'GET' }) + .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('load state failed')); }) + .then(function (data) { + applyWebshellPersistState(conn.id, data && data.state ? data.state : {}); + webshellPersistLoadedByConn[conn.id] = true; + }) + .catch(function () { + webshellPersistLoadedByConn[conn.id] = true; + }); +} + +function setWebshellTerminalStatus(running) { + webshellTerminalRunning = !!running; + var el = document.getElementById('webshell-terminal-status'); + if (!el) return; + el.classList.toggle('running', !!running); + el.classList.toggle('idle', !running); + el.textContent = running ? (wsT('webshell.terminalRunning') || '执行中') : (wsT('webshell.terminalIdle') || '空闲'); +} + +function renderWebshellTerminalSessions(conn) { + if (!conn || !conn.id) return; + var tabsEl = document.getElementById('webshell-terminal-sessions'); + if (!tabsEl) return; + var connId = conn.id; + var state = getWebshellTerminalSessions(connId); + var html = ''; + state.sessions.forEach(function (s) { + var active = s.id === state.activeId; + html += '