/** * 系统设置 - 终端:多标签、流式输出、命令历史、Ctrl+L 清屏、长时间可取消 */ (function () { var getContext = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function (type, attrs) { if (type === '2d') { attrs = (attrs && typeof attrs === 'object') ? Object.assign({ willReadFrequently: true }, attrs) : { willReadFrequently: true }; return getContext.call(this, type, attrs); } return getContext.apply(this, arguments); }; var terminals = []; var currentTabId = 1; var inited = false; var tabIdCounter = 1; var PROMPT = ''; // 真实 Shell 自己输出提示符,这里不再自定义 var HISTORY_MAX = 100; var CANCEL_AFTER_MS = 125000; function getCurrent() { for (var i = 0; i < terminals.length; i++) { if (terminals[i].id === currentTabId) return terminals[i]; } return terminals[0] || null; } function tr(key, opts) { if (typeof window !== 'undefined' && typeof window.t === 'function') { return window.t(key, opts); } // i18n 未就绪时的后备(与 zh-CN 一致) var fallbacks = { 'settingsTerminal.welcomeLine': 'CyberStrikeAI 终端 - 真实 Shell 会话,直接输入命令;Ctrl+L 清屏', 'settingsTerminal.sessionClosed': '[会话已关闭]', 'settingsTerminal.connectionError': '[终端连接出错]', 'settingsTerminal.connectFailed': '[无法连接终端服务: {{msg}}]', 'settingsTerminal.closeTabTitle': '关闭', 'settingsTerminal.containerClickTitle': '点击此处后输入命令', 'settingsTerminal.xtermNotLoaded': '未加载 xterm.js,请刷新页面或检查网络。', 'settingsTerminal.terminalTab': '终端 {{n}}' }; var s = fallbacks[key] || key; if (opts && typeof opts === 'object') { Object.keys(opts).forEach(function (k) { s = s.split('{{' + k + '}}').join(String(opts[k])); }); } return s; } function getWelcomeLine() { return tr('settingsTerminal.welcomeLine') + '\r\n'; } function writePrompt(tab) { // 提示符交由后端 Shell 自行输出,这里仅保留占位函数,避免旧代码报错 } function redrawTabDisplay(t) { if (!t || !t.term) return; t.term.clear(); t.term.write(getWelcomeLine()); } function writeln(tabOrS, s) { var t, text; if (arguments.length === 1) { text = tabOrS; t = getCurrent(); } else { t = tabOrS; text = s; } if (!t || !t.term) return; if (text) t.term.writeln(text); else t.term.writeln(''); } function writeOutput(tab, text, isError) { var t = tab || getCurrent(); if (!t || !t.term || !text) return; var s = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); var lines = s.split('\n'); var prefix = isError ? '\x1b[31m' : ''; var suffix = isError ? '\x1b[0m' : ''; t.term.write(prefix); for (var i = 0; i < lines.length; i++) { var line = lines[i].replace(/\r/g, ''); t.term.writeln(line); } t.term.write(suffix); } // 从本地存储中获取当前登录 token(与 auth.js 使用的结构保持一致) function getStoredAuthToken() { try { var raw = localStorage.getItem('cyberstrike-auth'); if (!raw) return null; var o = JSON.parse(raw); if (o && o.token) return o.token; } catch (e) {} return null; } // WebSocket 地址构造(兼容 http/https,并通过 query 传递 token 以通过后端鉴权) function buildTerminalWSURL() { var proto = (window.location.protocol === 'https:') ? 'wss://' : 'ws://'; var url = proto + window.location.host + '/api/terminal/ws'; var token = getStoredAuthToken(); if (token) { url += '?token=' + encodeURIComponent(token); } return url; } function ensureTerminalWS(tab) { if (tab.ws && (tab.ws.readyState === WebSocket.OPEN || tab.ws.readyState === WebSocket.CONNECTING)) { return; } try { var ws = new WebSocket(buildTerminalWSURL()); tab.ws = ws; tab.running = true; ws.onopen = function () { if (tab.term) { tab.term.focus(); } }; ws.onmessage = function (ev) { if (!tab.term) return; // 处理二进制消息和文本消息 if (ev.data instanceof ArrayBuffer) { var decoder = new TextDecoder('utf-8'); tab.term.write(decoder.decode(ev.data)); } else if (ev.data instanceof Blob) { // Blob 类型,需要异步读取 var reader = new FileReader(); reader.onload = function () { var decoder = new TextDecoder('utf-8'); tab.term.write(decoder.decode(reader.result)); }; reader.readAsArrayBuffer(ev.data); } else { // 字符串类型 tab.term.write(ev.data); } }; ws.onclose = function () { tab.running = false; if (tab.term) { tab.term.writeln('\r\n\x1b[2m' + tr('settingsTerminal.sessionClosed') + '\x1b[0m'); } }; ws.onerror = function () { tab.running = false; if (tab.term) { tab.term.writeln('\r\n\x1b[31m' + tr('settingsTerminal.connectionError') + '\x1b[0m'); } }; } catch (e) { if (tab.term) { tab.term.writeln('\r\n\x1b[31m' + tr('settingsTerminal.connectFailed', { msg: String(e) }) + '\x1b[0m'); } } } function createTerminalInContainer(container, tab) { if (typeof Terminal === 'undefined') return null; if (!tab.history) tab.history = []; if (tab.historyIndex === undefined) tab.historyIndex = -1; if (tab.cursorIndex === undefined) tab.cursorIndex = 0; var term = new Terminal({ cursorBlink: true, cursorStyle: 'bar', fontSize: 13, fontFamily: 'Menlo, Monaco, "Courier New", monospace', lineHeight: 1.2, scrollback: 1000, theme: { background: '#0d1117', foreground: '#e6edf3', cursor: '#58a6ff', cursorAccent: '#0d1117', selection: 'rgba(88, 166, 255, 0.3)', black: '#484f58', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff', magenta: '#bc8cff', cyan: '#39c5cf', white: '#e6edf3', brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff', brightCyan: '#56d4dd', brightWhite: '#f0f6fc' } }); var fitAddon = null; if (typeof FitAddon !== 'undefined') { var FitCtor = (FitAddon.FitAddon || FitAddon); fitAddon = new FitCtor(); term.loadAddon(fitAddon); } term.open(container); term.write(getWelcomeLine()); container.addEventListener('click', function () { switchTerminalTab(tab.id); if (term) term.focus(); }); container.setAttribute('tabindex', '0'); container.title = tr('settingsTerminal.containerClickTitle'); function sendToWS(data) { ensureTerminalWS(tab); if (tab.ws && tab.ws.readyState === WebSocket.OPEN) { try { tab.ws.send(data); } catch (e) {} } } term.onData(function (data) { // Ctrl+L:本地清屏,同时把 ^L 也发给后端 if (data === '\x0c') { term.clear(); sendToWS(data); return; } sendToWS(data); }); tab.term = term; tab.fitAddon = fitAddon; // 立即建立 WebSocket,让后端 PTY/Shell 马上启动并输出提示符; // 若等到首次按键才 connect,用户会感觉必须先按回车才能输入(实为连接尚未建立)。 ensureTerminalWS(tab); return term; } function switchTerminalTab(id) { var prevId = currentTabId; currentTabId = id; document.querySelectorAll('.terminal-tab').forEach(function (el) { el.classList.toggle('active', parseInt(el.getAttribute('data-tab-id'), 10) === id); }); document.querySelectorAll('.terminal-pane').forEach(function (el) { var paneId = el.getAttribute('id'); var match = paneId && paneId.match(/terminal-pane-(\d+)/); var paneTabId = match ? parseInt(match[1], 10) : 0; el.classList.toggle('active', paneTabId === id); }); var t = getCurrent(); if (t && t.term) { if (prevId !== id) { requestAnimationFrame(function () { if (currentTabId === id && t.term) t.term.focus(); }); } else { t.term.focus(); } } } function addTerminalTab() { if (typeof Terminal === 'undefined') return; tabIdCounter += 1; var id = tabIdCounter; var paneId = 'terminal-pane-' + id; var containerId = 'terminal-container-' + id; var tabsEl = document.querySelector('.terminal-tabs'); var panesEl = document.querySelector('.terminal-panes'); if (!tabsEl || !panesEl) return; var tabDiv = document.createElement('div'); tabDiv.className = 'terminal-tab'; tabDiv.setAttribute('data-tab-id', String(id)); var label = document.createElement('span'); label.className = 'terminal-tab-label'; label.textContent = tr('settingsTerminal.terminalTab', { n: id }); label.onclick = function () { switchTerminalTab(id); }; var closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'terminal-tab-close'; closeBtn.title = tr('settingsTerminal.closeTabTitle'); closeBtn.textContent = '×'; closeBtn.onclick = function (e) { e.stopPropagation(); removeTerminalTab(id); }; tabDiv.appendChild(label); tabDiv.appendChild(closeBtn); var plusBtn = tabsEl.querySelector('.terminal-tab-new'); tabsEl.insertBefore(tabDiv, plusBtn); var paneDiv = document.createElement('div'); paneDiv.id = paneId; paneDiv.className = 'terminal-pane'; var containerDiv = document.createElement('div'); containerDiv.id = containerId; containerDiv.className = 'terminal-container'; paneDiv.appendChild(containerDiv); panesEl.appendChild(paneDiv); var tab = { id: id, paneId: paneId, containerId: containerId, lineBuffer: '', cursorIndex: 0, running: false, term: null, fitAddon: null, history: [], historyIndex: -1 }; terminals.push(tab); createTerminalInContainer(containerDiv, tab); switchTerminalTab(id); updateTerminalTabCloseVisibility(); setTimeout(function () { try { if (tab.fitAddon) tab.fitAddon.fit(); if (tab.term) tab.term.focus(); } catch (e) {} }, 50); } function updateTerminalTabCloseVisibility() { var tabsEl = document.querySelector('.terminal-tabs'); if (!tabsEl) return; var tabDivs = tabsEl.querySelectorAll('.terminal-tab'); var showClose = terminals.length > 1; for (var i = 0; i < tabDivs.length; i++) { var btn = tabDivs[i].querySelector('.terminal-tab-close'); if (btn) btn.style.display = showClose ? '' : 'none'; } } function removeTerminalTab(id) { if (terminals.length <= 1) return; var idx = -1; for (var i = 0; i < terminals.length; i++) { if (terminals[i].id === id) { idx = i; break; } } if (idx < 0) return; var deletingCurrent = (currentTabId === id); var switchToIndex = deletingCurrent ? (idx > 0 ? idx - 1 : 0) : -1; var tab = terminals[idx]; if (tab.term && tab.term.dispose) tab.term.dispose(); tab.term = null; tab.fitAddon = null; terminals.splice(idx, 1); var tabDiv = document.querySelector('.terminal-tab[data-tab-id="' + id + '"]'); var paneDiv = document.getElementById('terminal-pane-' + id); if (tabDiv && tabDiv.parentNode) tabDiv.parentNode.removeChild(tabDiv); if (paneDiv && paneDiv.parentNode) paneDiv.parentNode.removeChild(paneDiv); var curIdxBeforeRenumber = -1; if (!deletingCurrent) { for (var i = 0; i < terminals.length; i++) { if (terminals[i].id === currentTabId) { curIdxBeforeRenumber = i; break; } } } for (var i = 0; i < terminals.length; i++) { var t = terminals[i]; t.id = i + 1; t.paneId = 'terminal-pane-' + (i + 1); t.containerId = 'terminal-container-' + (i + 1); } tabIdCounter = terminals.length; if (curIdxBeforeRenumber >= 0) currentTabId = terminals[curIdxBeforeRenumber].id; var tabsEl = document.querySelector('.terminal-tabs'); var panesEl = document.querySelector('.terminal-panes'); if (tabsEl) { var tabDivs = tabsEl.querySelectorAll('.terminal-tab'); for (var i = 0; i < tabDivs.length; i++) { var t = terminals[i]; tabDivs[i].setAttribute('data-tab-id', String(t.id)); var lbl = tabDivs[i].querySelector('.terminal-tab-label'); if (lbl) lbl.textContent = tr('settingsTerminal.terminalTab', { n: t.id }); if (lbl) lbl.onclick = (function (tid) { return function () { switchTerminalTab(tid); }; })(t.id); var cb = tabDivs[i].querySelector('.terminal-tab-close'); if (cb) cb.onclick = (function (tid) { return function (e) { e.stopPropagation(); removeTerminalTab(tid); }; })(t.id); } } if (panesEl) { var paneDivs = panesEl.querySelectorAll('.terminal-pane'); for (var i = 0; i < paneDivs.length; i++) { var t = terminals[i]; paneDivs[i].id = t.paneId; var cont = paneDivs[i].querySelector('.terminal-container'); if (cont) cont.id = t.containerId; } } updateTerminalTabCloseVisibility(); if (deletingCurrent && terminals.length > 0) { currentTabId = terminals[switchToIndex].id; switchTerminalTab(currentTabId); } } function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function refreshTerminalI18n() { // 语言切换后更新标签与容器 title;已打开的终端内容不强制清屏,以免丢失会话输出 try { var tabsEl = document.querySelector('.terminal-tabs'); if (tabsEl) { var tabDivs = tabsEl.querySelectorAll('.terminal-tab'); for (var i = 0; i < tabDivs.length && i < terminals.length; i++) { var tid = terminals[i].id; var lbl = tabDivs[i].querySelector('.terminal-tab-label'); if (lbl) lbl.textContent = tr('settingsTerminal.terminalTab', { n: tid }); var cb = tabDivs[i].querySelector('.terminal-tab-close'); if (cb) cb.title = tr('settingsTerminal.closeTabTitle'); } } terminals.forEach(function (tab) { if (!tab || !tab.term) return; var cont = document.getElementById(tab.containerId); if (cont) cont.title = tr('settingsTerminal.containerClickTitle'); }); } catch (e) { /* ignore */ } } document.addEventListener('languagechange', function () { refreshTerminalI18n(); }); function initTerminal() { var pane1 = document.getElementById('terminal-pane-1'); var container1 = document.getElementById('terminal-container-1'); if (!pane1 || !container1) return; if (inited) { var t = getCurrent(); if (t && t.term) t.term.focus(); terminals.forEach(function (tab) { try { if (tab.fitAddon) tab.fitAddon.fit(); } catch (e) {} }); return; } inited = true; if (typeof Terminal === 'undefined') { container1.innerHTML = '
' + escapeHtml(tr('settingsTerminal.xtermNotLoaded')) + '
'; return; } currentTabId = 1; var tab = { id: 1, paneId: 'terminal-pane-1', containerId: 'terminal-container-1', lineBuffer: '', cursorIndex: 0, running: false, term: null, fitAddon: null, history: [], historyIndex: -1 }; terminals.push(tab); createTerminalInContainer(container1, tab); updateTerminalTabCloseVisibility(); refreshTerminalI18n(); setTimeout(function () { try { if (tab.fitAddon) tab.fitAddon.fit(); if (tab.term) tab.term.focus(); } catch (e) {} }, 100); var resizeTimer; window.addEventListener('resize', function () { clearTimeout(resizeTimer); resizeTimer = setTimeout(function () { terminals.forEach(function (t) { try { if (t.fitAddon) t.fitAddon.fit(); } catch (e) {} }); }, 150); }); } function terminalClear() { var t = getCurrent(); if (!t || !t.term) return; t.term.clear(); t.lineBuffer = ''; if (t.cursorIndex !== undefined) t.cursorIndex = 0; writePrompt(t); t.term.focus(); } window.initTerminal = initTerminal; window.terminalClear = terminalClear; window.switchTerminalTab = switchTerminalTab; window.addTerminalTab = addTerminalTab; window.removeTerminalTab = removeTerminalTab; })();