// WebShell 管理(类似冰蝎/蚁剑:虚拟终端、文件管理、命令执行) const WEBSHELL_SIDEBAR_WIDTH_KEY = 'webshell_sidebar_width'; const WEBSHELL_DEFAULT_SIDEBAR_WIDTH = 360; /** 右侧主区域(终端/文件管理)最小宽度,避免拖到中间时右边变形 */ const WEBSHELL_MAIN_MIN_WIDTH = 380; const WEBSHELL_PROMPT = 'shell> '; let webshellConnections = []; let currentWebshellId = null; let webshellTerminalInstance = null; let webshellTerminalFitAddon = null; let webshellTerminalResizeObserver = null; 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; const WEBSHELL_HISTORY_MAX = 100; // 清屏防重入:一次点击只执行一次(避免多次绑定或重复触发导致多个 shell>) let webshellClearInProgress = false; // AI 助手:按连接 ID 保存对话 ID,便于多轮对话 let webshellAiConvMap = {}; let webshellAiSending = false; let webshellDbConfigByConn = {}; let webshellDirTreeByConn = {}; let webshellDirExpandedByConn = {}; let webshellDirLoadedByConn = {}; // 流式打字机效果:当前会话的 response 序号,用于中止过期的打字 let webshellStreamingTypingId = 0; let webshellProbeStatusById = {}; let webshellBatchProbeRunning = false; /** 与主对话页一致:multi_agent.enabled 且本地模式为 multi 时使用 /api/multi-agent/stream */ function resolveWebshellAiStreamPath() { if (typeof apiFetch === 'undefined') { return Promise.resolve('/api/agent-loop/stream'); } return apiFetch('/api/config').then(function (r) { if (!r.ok) return '/api/agent-loop/stream'; return r.json(); }).then(function (cfg) { if (!cfg || !cfg.multi_agent || !cfg.multi_agent.enabled) return '/api/agent-loop/stream'; var mode = localStorage.getItem('cyberstrike-chat-agent-mode'); if (mode !== 'single' && mode !== 'multi') { mode = (cfg.multi_agent.default_mode === 'multi') ? 'multi' : 'single'; } return mode === 'multi' ? '/api/multi-agent/stream' : '/api/agent-loop/stream'; }).catch(function () { return '/api/agent-loop/stream'; }); } // 从服务端(SQLite)拉取连接列表 function getWebshellConnections() { if (typeof apiFetch === 'undefined') { return Promise.resolve([]); } return apiFetch('/api/webshell/connections', { method: 'GET' }) .then(function (r) { return r.json(); }) .then(function (list) { return Array.isArray(list) ? list : []; }) .catch(function (e) { console.warn('读取 WebShell 连接列表失败', e); return []; }); } // 从服务端刷新连接列表并重绘侧栏 function refreshWebshellConnectionsFromServer() { return getWebshellConnections().then(function (list) { webshellConnections = list; renderWebshellList(); return list; }); } // 使用 wsT 避免与全局 window.t 冲突导致无限递归 function wsT(key) { var globalT = typeof window !== 'undefined' ? window.t : null; if (typeof globalT === 'function' && globalT !== wsT) return globalT(key); var fallback = { 'webshell.title': 'WebShell 管理', 'webshell.addConnection': '添加连接', 'webshell.cmdParam': '命令参数名', 'webshell.cmdParamPlaceholder': '不填默认为 cmd,如填 xxx 则请求为 xxx=命令', 'webshell.connections': '连接列表', 'webshell.noConnections': '暂无连接,请点击「添加连接」', 'webshell.selectOrAdd': '请从左侧选择连接,或添加新的 WebShell 连接', 'webshell.deleteConfirm': '确定要删除该连接吗?', 'webshell.editConnection': '编辑', 'webshell.editConnectionTitle': '编辑连接', 'webshell.tabTerminal': '虚拟终端', 'webshell.tabFileManager': '文件管理', 'webshell.tabAiAssistant': 'AI 助手', 'webshell.tabDbManager': '数据库管理', 'webshell.tabMemo': '备忘录', 'webshell.dbType': '数据库类型', 'webshell.dbHost': '主机', 'webshell.dbPort': '端口', 'webshell.dbUsername': '用户名', 'webshell.dbPassword': '密码', 'webshell.dbName': '数据库名', 'webshell.dbSqlitePath': 'SQLite 文件路径', 'webshell.dbSqlPlaceholder': '输入 SQL,例如:SELECT version();', 'webshell.dbRunSql': '执行 SQL', 'webshell.dbTest': '测试连接', 'webshell.dbOutput': '执行输出', 'webshell.dbNoConn': '请先选择 WebShell 连接', 'webshell.dbSqlRequired': '请输入 SQL', 'webshell.dbRunning': '数据库命令执行中,请稍候', 'webshell.dbCliHint': '如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd)', 'webshell.dbExecFailed': '数据库执行失败', 'webshell.dbSchema': '数据库结构', 'webshell.dbLoadSchema': '加载结构', 'webshell.dbNoSchema': '暂无数据库结构,请先加载', 'webshell.dbSelectTableHint': '点击表名可展开列信息并生成查询 SQL', 'webshell.dbNoColumns': '暂无列信息', 'webshell.dbResultTable': '结果表格', 'webshell.dbClearSql': '清空 SQL', 'webshell.dbTemplateSql': '示例 SQL', 'webshell.dbRows': '行', 'webshell.dbColumns': '列', 'webshell.dbSchemaFailed': '加载数据库结构失败', 'webshell.dbSchemaLoaded': '结构加载完成', 'webshell.dbAddProfile': '新增连接', 'webshell.dbExecSuccess': 'SQL 执行成功', 'webshell.dbNoOutput': '执行完成(无输出)', 'webshell.dbRenameProfile': '重命名', 'webshell.dbDeleteProfile': '删除连接', 'webshell.dbDeleteProfileConfirm': '确定删除该数据库连接配置吗?', 'webshell.dbProfileNamePrompt': '请输入连接名称', 'webshell.dbProfileName': '连接名称', 'webshell.dbProfiles': '数据库连接', 'webshell.aiSystemReadyMessage': '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。', 'webshell.aiPlaceholder': '例如:列出当前目录下的文件', 'webshell.aiSend': '发送', 'webshell.aiMemo': '备忘录', 'webshell.aiMemoPlaceholder': '记录关键命令、测试思路、复现步骤...', 'webshell.aiMemoClear': '清空', 'webshell.aiMemoSaving': '保存中...', 'webshell.aiMemoSaved': '已保存到本地', 'webshell.terminalWelcome': 'WebShell 虚拟终端 — 输入命令后按回车执行(Ctrl+L 清屏)', 'webshell.quickCommands': '快捷命令', 'webshell.downloadFile': '下载', 'webshell.filePath': '当前路径', 'webshell.listDir': '列出目录', 'webshell.readFile': '读取', 'webshell.editFile': '编辑', 'webshell.deleteFile': '删除', 'webshell.saveFile': '保存', 'webshell.cancelEdit': '取消', 'webshell.parentDir': '上级目录', 'webshell.execError': '执行失败', 'webshell.testConnectivity': '测试连通性', 'webshell.testSuccess': '连通性正常,Shell 可访问', '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': '新建目录', 'webshell.rename': '重命名', 'webshell.upload': '上传', 'webshell.newFile': '新建文件', 'webshell.filterPlaceholder': '过滤文件名', 'webshell.batchDelete': '批量删除', 'webshell.batchDownload': '批量下载', 'webshell.moreActions': '更多操作', 'webshell.refresh': '刷新', 'webshell.selectAll': '全选', 'webshell.breadcrumbHome': '根', 'webshell.dirTree': '目录列表', 'webshell.searchPlaceholder': '搜索连接...', 'webshell.noMatchConnections': '暂无匹配连接', 'webshell.batchProbe': '一键批量探活', 'webshell.probeRunning': '探活中', 'webshell.probeOnline': '在线', 'webshell.probeOffline': '离线', 'webshell.probeNoConnections': '暂无可探活连接', 'webshell.back': '返回', 'webshell.colModifiedAt': '修改时间', 'webshell.colPerms': '权限', 'webshell.colOwner': '所有者', 'webshell.colGroup': '用户组', 'webshell.colType': '类型', 'common.delete': '删除', 'common.refresh': '刷新', 'common.actions': '操作' }; return fallback[key] || key; } function wsTOr(key, fallbackText) { var text = wsT(key); if (!text || text === key) return fallbackText; return text; } // 全局只绑定一次:清屏 = 销毁终端并重新创建,保证只出现一个 shell>(不依赖 xterm.clear(),避免某些环境下 clear 不生效或重复写入) function bindWebshellClearOnce() { if (window._webshellClearBound) return; window._webshellClearBound = true; document.body.addEventListener('click', function (e) { var btn = e.target && (e.target.id === 'webshell-terminal-clear' ? e.target : e.target.closest ? e.target.closest('#webshell-terminal-clear') : null); if (!btn || !webshellCurrentConn) return; e.preventDefault(); e.stopPropagation(); if (webshellClearInProgress) return; webshellClearInProgress = true; try { 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); } }, true); } // WebShell 行内/工具栏“操作”下拉:点击菜单外自动收起 function bindWebshellActionMenusAutoCloseOnce() { if (window._webshellActionMenusAutoCloseBound) return; window._webshellActionMenusAutoCloseBound = true; document.addEventListener('click', function (e) { // 只要点在 details 内部,就让浏览器自行切换(open/close) var clickedInMenu = e.target && e.target.closest && ( e.target.closest('details.webshell-conn-actions') || e.target.closest('details.webshell-row-actions') || e.target.closest('details.webshell-toolbar-actions') ); if (clickedInMenu) return; var openDetails = document.querySelectorAll( 'details.webshell-conn-actions[open],details.webshell-row-actions[open],details.webshell-toolbar-actions[open]' ); openDetails.forEach(function (d) { d.open = false; }); }, true); } // 初始化 WebShell 管理页面(从 SQLite 拉取连接列表) function initWebshellPage() { bindWebshellClearOnce(); bindWebshellActionMenusAutoCloseOnce(); destroyWebshellTerminal(); webshellCurrentConn = null; currentWebshellId = null; webshellConnections = []; renderWebshellList(); applyWebshellSidebarWidth(); initWebshellSidebarResize(); // 连接搜索:实时过滤连接列表 var searchEl = document.getElementById('webshell-conn-search'); if (searchEl && searchEl.dataset.bound !== '1') { searchEl.dataset.bound = '1'; searchEl.addEventListener('input', function () { renderWebshellList(); }); } const workspace = document.getElementById('webshell-workspace'); if (workspace) { workspace.innerHTML = '
' + (wsT('webshell.selectOrAdd')) + '
'; } getWebshellConnections().then(function (list) { webshellConnections = list; renderWebshellList(); }); var batchProbeBtn = document.getElementById('webshell-batch-probe-btn'); if (batchProbeBtn && batchProbeBtn.dataset.bound !== '1') { batchProbeBtn.dataset.bound = '1'; batchProbeBtn.addEventListener('click', function () { runBatchProbeWebshellConnections(); }); } updateWebshellBatchProbeButton(); } function getWebshellSidebarWidth() { try { const w = parseInt(localStorage.getItem(WEBSHELL_SIDEBAR_WIDTH_KEY), 10); if (!isNaN(w) && w >= 260 && w <= 800) return w; } catch (e) {} return WEBSHELL_DEFAULT_SIDEBAR_WIDTH; } function setWebshellSidebarWidth(px) { localStorage.setItem(WEBSHELL_SIDEBAR_WIDTH_KEY, String(px)); } function applyWebshellSidebarWidth() { const sidebar = document.getElementById('webshell-sidebar'); if (!sidebar) return; const parentW = sidebar.parentElement ? sidebar.parentElement.offsetWidth : 0; let w = getWebshellSidebarWidth(); if (parentW > 0) w = Math.min(w, Math.max(260, parentW - WEBSHELL_MAIN_MIN_WIDTH)); sidebar.style.width = w + 'px'; } function initWebshellSidebarResize() { const handle = document.getElementById('webshell-resize-handle'); const sidebar = document.getElementById('webshell-sidebar'); if (!handle || !sidebar || handle.dataset.resizeBound === '1') return; handle.dataset.resizeBound = '1'; let startX = 0, startW = 0; function onMove(e) { const dx = e.clientX - startX; let w = Math.round(startW + dx); const parentW = sidebar.parentElement ? sidebar.parentElement.offsetWidth : 800; const min = 260; const max = Math.min(800, parentW - WEBSHELL_MAIN_MIN_WIDTH); w = Math.max(min, Math.min(max, w)); sidebar.style.width = w + 'px'; } function onUp() { handle.classList.remove('active'); document.body.style.cursor = ''; document.body.style.userSelect = ''; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); setWebshellSidebarWidth(parseInt(sidebar.style.width, 10) || WEBSHELL_DEFAULT_SIDEBAR_WIDTH); } handle.addEventListener('mousedown', function (e) { if (e.button !== 0) return; e.preventDefault(); startX = e.clientX; startW = sidebar.offsetWidth; handle.classList.add('active'); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); } // 销毁当前终端实例(切换连接或离开页面时) function destroyWebshellTerminal() { if (webshellTerminalResizeObserver && webshellTerminalResizeContainer) { try { webshellTerminalResizeObserver.unobserve(webshellTerminalResizeContainer); } catch (e) {} webshellTerminalResizeObserver = null; webshellTerminalResizeContainer = null; } if (webshellTerminalInstance) { try { webshellTerminalInstance.dispose(); } catch (e) {} webshellTerminalInstance = null; } webshellTerminalFitAddon = null; webshellLineBuffer = ''; webshellRunning = false; webshellTerminalRunning = false; setWebshellTerminalStatus(false); } // 渲染连接列表 function renderWebshellList() { const listEl = document.getElementById('webshell-list'); if (!listEl) return; const searchEl = document.getElementById('webshell-conn-search'); const searchTerm = (searchEl && typeof searchEl.value === 'string' ? searchEl.value : '').trim().toLowerCase(); if (!webshellConnections.length) { listEl.innerHTML = '
' + (wsT('webshell.noConnections')) + '
'; return; } const filtered = searchTerm ? webshellConnections.filter(conn => { const id = String(conn.id || '').toLowerCase(); const url = String(conn.url || '').toLowerCase(); const remark = String(conn.remark || '').toLowerCase(); return id.includes(searchTerm) || url.includes(searchTerm) || remark.includes(searchTerm); }) : webshellConnections; if (filtered.length === 0) { listEl.innerHTML = '
' + (wsT('webshell.noMatchConnections') || '暂无匹配连接') + '
'; return; } listEl.innerHTML = filtered.map(conn => { const remark = (conn.remark || conn.url || '').replace(//g, '>'); const url = (conn.url || '').replace(//g, '>'); const urlTitle = (conn.url || '').replace(/&/g, '&').replace(/"/g, '"').replace(/'; } else if (probe && probe.state === 'ok') { probeHtml = '' + (wsT('webshell.probeOnline') || '在线') + ''; } else if (probe && probe.state === 'fail') { probeHtml = '' + (wsT('webshell.probeOffline') || '离线') + ''; } return ( '
' + '
' + remark + '
' + probeHtml + '
' + '
' + url + '
' + '
' + '
' + actionsLabel + '' + '
' + '' + '' + '
' + '
' + '
' ); }).join(''); listEl.querySelectorAll('.webshell-item').forEach(el => { el.addEventListener('click', function (e) { if (e.target.closest('.webshell-delete-btn') || e.target.closest('.webshell-edit-conn-btn') || e.target.closest('.webshell-conn-actions-btn')) return; selectWebshell(el.getAttribute('data-id')); }); }); listEl.querySelectorAll('.webshell-edit-conn-btn').forEach(btn => { btn.addEventListener('click', function (e) { e.stopPropagation(); showEditWebshellModal(btn.getAttribute('data-id')); }); }); listEl.querySelectorAll('.webshell-delete-btn').forEach(btn => { btn.addEventListener('click', function (e) { e.stopPropagation(); deleteWebshell(btn.getAttribute('data-id')); }); }); } function probeWebshellConnection(conn) { if (!conn || typeof apiFetch === 'undefined') { return Promise.resolve({ ok: false, message: wsT('webshell.testFailed') || '连通性测试失败' }); } return apiFetch('/api/webshell/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: ((conn.method || 'post').toLowerCase() === 'get') ? 'get' : 'post', cmd_param: conn.cmdParam || '', command: 'echo 1' }) }) .then(function (r) { return r.json(); }) .then(function (data) { var output = (data && data.output != null) ? String(data.output).trim() : ''; var ok = !!(data && data.ok && output === '1'); if (ok) return { ok: true, message: wsT('webshell.testSuccess') || '连通性正常,Shell 可访问' }; var msg = (data && data.error) ? data.error : (wsT('webshell.testFailed') || '连通性测试失败'); return { ok: false, message: msg }; }) .catch(function (e) { return { ok: false, message: (e && e.message) ? e.message : String(e) }; }); } function updateWebshellBatchProbeButton(done, total, okCount) { var btn = document.getElementById('webshell-batch-probe-btn'); if (!btn) return; if (webshellBatchProbeRunning) { var d = typeof done === 'number' ? done : 0; var t = typeof total === 'number' ? total : webshellConnections.length; btn.disabled = true; btn.textContent = (wsT('webshell.probeRunning') || '探活中') + ' ' + d + '/' + t; return; } btn.disabled = false; if (typeof done === 'number' && typeof total === 'number' && total > 0 && typeof okCount === 'number') { btn.textContent = (wsT('webshell.batchProbe') || '一键批量探活') + ' (' + okCount + '/' + total + ')'; } else { btn.textContent = wsT('webshell.batchProbe') || '一键批量探活'; } } function runBatchProbeWebshellConnections() { if (webshellBatchProbeRunning) return; if (!Array.isArray(webshellConnections) || webshellConnections.length === 0) { alert(wsT('webshell.probeNoConnections') || '暂无可探活连接'); return; } webshellBatchProbeRunning = true; var total = webshellConnections.length; var done = 0; var okCount = 0; webshellConnections.forEach(function (conn) { if (!conn || !conn.id) return; webshellProbeStatusById[conn.id] = { state: 'probing', message: '' }; }); renderWebshellList(); updateWebshellBatchProbeButton(done, total, okCount); var idx = 0; var concurrency = Math.min(4, total); function runOne() { if (idx >= total) return Promise.resolve(); var conn = webshellConnections[idx++]; if (!conn || !conn.id) { done++; updateWebshellBatchProbeButton(done, total, okCount); return runOne(); } return probeWebshellConnection(conn).then(function (res) { if (res.ok) okCount++; webshellProbeStatusById[conn.id] = { state: res.ok ? 'ok' : 'fail', message: res.message || '' }; done++; renderWebshellList(); updateWebshellBatchProbeButton(done, total, okCount); }).then(runOne); } var workers = []; for (var i = 0; i < concurrency; i++) workers.push(runOne()); Promise.all(workers).finally(function () { webshellBatchProbeRunning = false; updateWebshellBatchProbeButton(done, total, okCount); }); } function escapeHtml(s) { if (!s) return ''; const div = document.createElement('div'); div.textContent = s; return div.innerHTML; } function escapeSingleQuotedShellArg(value) { var s = value == null ? '' : String(value); return "'" + s.replace(/'/g, "'\\''") + "'"; } function safeConnIdForStorage(conn) { if (!conn || !conn.id) return ''; return String(conn.id).replace(/[^\w.-]/g, '_'); } function normalizeWebshellPath(path) { var p = path == null ? '.' : String(path).trim(); if (!p || p === '/') return '.'; p = p.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/'); if (!p || p === '.') return '.'; if (p.endsWith('/')) p = p.slice(0, -1); 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 += '
' + '' + '' + '
'; }); html += ''; tabsEl.innerHTML = html; tabsEl.querySelectorAll('[data-action]').forEach(function (btn) { btn.addEventListener('click', function () { var action = btn.getAttribute('data-action') || ''; var targetId = btn.getAttribute('data-terminal-id') || ''; if (webshellRunning || webshellTerminalRunning) return; if (action === 'add') { var nextState = getWebshellTerminalSessions(connId); var seq = nextState.sessions.length + 1; var nextId = 't' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); var prefix = wsT('webshell.terminalWindowPrefix') || '终端'; nextState.sessions.push({ id: nextId, name: prefix + seq }); nextState.activeId = nextId; saveWebshellTerminalSessions(connId, nextState); destroyWebshellTerminal(); initWebshellTerminal(conn); renderWebshellTerminalSessions(conn); return; } if (!targetId) return; if (action === 'close') { var curr2 = getWebshellTerminalSessions(connId); if (curr2.sessions.length <= 1) return; var idx2 = curr2.sessions.findIndex(function (s) { return s.id === targetId; }); if (idx2 < 0) return; curr2.sessions.splice(idx2, 1); if (curr2.activeId === targetId) { var fallback = curr2.sessions[Math.max(0, idx2 - 1)] || curr2.sessions[0]; curr2.activeId = fallback.id; } saveWebshellTerminalSessions(connId, curr2); // 清理该终端的日志与历史 var terminalKey = getWebshellTerminalSessionKey(connId, targetId); clearWebshellTerminalLog(terminalKey); delete webshellHistoryByConn[terminalKey]; destroyWebshellTerminal(); initWebshellTerminal(conn); renderWebshellTerminalSessions(conn); return; } if (targetId === getActiveWebshellTerminalSessionId(connId)) return; var curr = getWebshellTerminalSessions(connId); curr.activeId = targetId; saveWebshellTerminalSessions(connId, curr); destroyWebshellTerminal(); initWebshellTerminal(conn); renderWebshellTerminalSessions(conn); }); }); } function getWebshellTreeState(conn) { var key = safeConnIdForStorage(conn); if (!key) return null; if (!webshellDirTreeByConn[key]) webshellDirTreeByConn[key] = { '.': [] }; if (!webshellDirExpandedByConn[key]) webshellDirExpandedByConn[key] = { '.': true }; if (!webshellDirLoadedByConn[key]) webshellDirLoadedByConn[key] = { '.': false }; return { key: key, tree: webshellDirTreeByConn[key], expanded: webshellDirExpandedByConn[key], loaded: webshellDirLoadedByConn[key] }; } function newWebshellDbProfile(name) { var now = Date.now().toString(36); var rand = Math.random().toString(36).slice(2, 8); return { id: 'dbp_' + now + rand, name: name || 'DB-1', type: 'mysql', host: '127.0.0.1', port: '3306', username: 'root', password: '', database: '', selectedDatabase: '', sqlitePath: '/tmp/test.db', sql: 'SELECT 1;', output: '', outputIsError: false, schema: {} }; } function getWebshellDbStateStorageKey(conn) { return 'webshell_db_state_' + safeConnIdForStorage(conn); } function normalizeWebshellDbState(rawState) { var state = rawState && typeof rawState === 'object' ? rawState : {}; var profiles = Array.isArray(state.profiles) ? state.profiles.slice() : []; if (!profiles.length) profiles = [newWebshellDbProfile('DB-1')]; profiles = profiles.map(function (p, idx) { var base = newWebshellDbProfile('DB-' + (idx + 1)); return Object.assign(base, p || {}); }); var activeProfileId = state.activeProfileId || ''; if (!profiles.some(function (p) { return p.id === activeProfileId; })) { activeProfileId = profiles[0].id; } var aiMemo = typeof state.aiMemo === 'string' ? state.aiMemo : ''; if (aiMemo.length > 100000) aiMemo = aiMemo.slice(0, 100000); return { profiles: profiles, activeProfileId: activeProfileId, aiMemo: aiMemo }; } function getWebshellDbState(conn) { var key = getWebshellDbStateStorageKey(conn); if (!key) return normalizeWebshellDbState(null); if (webshellDbConfigByConn[key]) return webshellDbConfigByConn[key]; var state = normalizeWebshellDbState(null); webshellDbConfigByConn[key] = state; return state; } function saveWebshellDbState(conn, state) { var key = getWebshellDbStateStorageKey(conn); if (!key || !state) return; var normalized = normalizeWebshellDbState(state); webshellDbConfigByConn[key] = normalized; if (conn && conn.id) queueWebshellPersistStateSave(conn.id); } function getWebshellDbConfig(conn) { var state = getWebshellDbState(conn); var active = state.profiles.find(function (p) { return p.id === state.activeProfileId; }); return active || state.profiles[0]; } function saveWebshellDbConfig(conn, cfg) { if (!cfg) return; var state = getWebshellDbState(conn); var idx = state.profiles.findIndex(function (p) { return p.id === state.activeProfileId; }); if (idx < 0) idx = 0; state.profiles[idx] = Object.assign({}, state.profiles[idx], cfg); state.activeProfileId = state.profiles[idx].id; saveWebshellDbState(conn, state); } function getWebshellAiMemo(conn) { var state = getWebshellDbState(conn); return typeof state.aiMemo === 'string' ? state.aiMemo : ''; } function saveWebshellAiMemo(conn, text) { var state = getWebshellDbState(conn); state.aiMemo = String(text || ''); if (state.aiMemo.length > 100000) state.aiMemo = state.aiMemo.slice(0, 100000); saveWebshellDbState(conn, state); } function webshellDbGetFieldValue(id) { var el = document.getElementById(id); return el && typeof el.value === 'string' ? el.value.trim() : ''; } function webshellDbCollectConfig(conn) { var curr = getWebshellDbConfig(conn) || {}; var nameVal = webshellDbGetFieldValue('webshell-db-profile-name'); var cfg = { name: nameVal || curr.name || 'DB-1', type: webshellDbGetFieldValue('webshell-db-type') || 'mysql', host: webshellDbGetFieldValue('webshell-db-host') || '127.0.0.1', port: webshellDbGetFieldValue('webshell-db-port') || '', username: webshellDbGetFieldValue('webshell-db-user') || '', password: (document.getElementById('webshell-db-pass') || {}).value || '', database: webshellDbGetFieldValue('webshell-db-name') || '', selectedDatabase: curr.selectedDatabase || '', sqlitePath: webshellDbGetFieldValue('webshell-db-sqlite-path') || '/tmp/test.db', sql: (document.getElementById('webshell-db-sql') || {}).value || '' }; saveWebshellDbConfig(conn, cfg); return cfg; } function webshellDbUpdateFieldVisibility() { var type = webshellDbGetFieldValue('webshell-db-type') || 'mysql'; var isSqlite = type === 'sqlite'; var blocks = document.querySelectorAll('.webshell-db-common-field'); blocks.forEach(function (el) { el.style.display = isSqlite ? 'none' : ''; }); var sqliteBlock = document.getElementById('webshell-db-sqlite-row'); if (sqliteBlock) sqliteBlock.style.display = isSqlite ? '' : 'none'; var portEl = document.getElementById('webshell-db-port'); if (portEl && !String(portEl.value || '').trim()) { if (type === 'mysql') portEl.value = '3306'; else if (type === 'pgsql') portEl.value = '5432'; else if (type === 'mssql') portEl.value = '1433'; } } function webshellDbSetOutput(text, isError) { var outputEl = document.getElementById('webshell-db-output'); if (!outputEl) return; outputEl.textContent = text || ''; outputEl.classList.toggle('error', !!isError); } function webshellDbRenderTable(rawOutput) { var wrap = document.getElementById('webshell-db-result-table'); if (!wrap) return false; var raw = String(rawOutput || '').trim(); if (!raw) { wrap.innerHTML = ''; return false; } var lines = raw.split(/\r?\n/).filter(function (line) { var t = String(line || '').trim(); if (!t) return false; if (/^\(\d+\s+rows?\)$/i.test(t)) return false; if (/^-{3,}$/.test(t)) return false; return true; }); if (lines.length < 2) { wrap.innerHTML = ''; return false; } var delimiter = lines[0].indexOf('\t') >= 0 ? '\t' : (lines[0].indexOf('|') >= 0 ? '|' : ''); if (!delimiter) { wrap.innerHTML = ''; return false; } var header = lines[0].split(delimiter).map(function (s) { return String(s || '').trim(); }); if (!header.length || (header.length === 1 && !header[0])) { wrap.innerHTML = ''; return false; } var rows = []; for (var i = 1; i < lines.length; i++) { var line = lines[i]; if (/^[-+\s|]+$/.test(line)) continue; var cols = line.split(delimiter).map(function (s) { return String(s || '').trim(); }); if (cols.length !== header.length) continue; rows.push(cols); } if (!rows.length) { wrap.innerHTML = ''; return false; } var maxRows = Math.min(rows.length, 200); var html = ''; header.forEach(function (h) { html += ''; }); html += ''; for (var r = 0; r < maxRows; r++) { html += ''; rows[r].forEach(function (c) { html += ''; }); html += ''; } html += '
' + escapeHtml(h || '-') + '
' + escapeHtml(c || '') + '
'; if (rows.length > maxRows) { html += '
仅展示前 ' + maxRows + ' 行,共 ' + rows.length + ' 行
'; } else { html += '
共 ' + rows.length + ' 行,' + header.length + ' 列
'; } wrap.innerHTML = html; return true; } function webshellDbQuoteIdentifier(type, name) { var v = String(name || ''); if (!v) return ''; if (type === 'mysql') return '`' + v.replace(/`/g, '``') + '`'; if (type === 'mssql') return '[' + v.replace(/]/g, ']]') + ']'; return '"' + v.replace(/"/g, '""') + '"'; } function webshellDbQuoteLiteral(value) { return "'" + String(value == null ? '' : value).replace(/'/g, "''") + "'"; } function buildWebshellDbCommand(cfg, isTestOnly, options) { options = options || {}; var type = cfg.type || 'mysql'; var sql = String(isTestOnly ? 'SELECT 1;' : (options.sql || cfg.sql || '')).trim(); if (!sql) return { error: wsT('webshell.dbSqlRequired') || '请输入 SQL' }; var sqlB64 = btoa(unescape(encodeURIComponent(sql))); var sqlB64Arg = escapeSingleQuotedShellArg(sqlB64); var tmpFile = '/tmp/.csai_sql_$$.sql'; var decodeToFile = 'printf %s ' + sqlB64Arg + " | base64 -d > " + tmpFile; var cleanup = '; rc=$?; rm -f ' + tmpFile + '; echo "__CSAI_DB_RC__:$rc"; exit $rc'; var command = ''; if (type === 'mysql') { var host = escapeSingleQuotedShellArg(cfg.host || '127.0.0.1'); var port = escapeSingleQuotedShellArg(cfg.port || '3306'); var user = escapeSingleQuotedShellArg(cfg.username || 'root'); var pass = escapeSingleQuotedShellArg(cfg.password || ''); var dbName = cfg.selectedDatabase || cfg.database || ''; var db = dbName ? (' -D ' + escapeSingleQuotedShellArg(dbName)) : ''; command = decodeToFile + '; MYSQL_PWD=' + pass + ' mysql -h ' + host + ' -P ' + port + ' -u ' + user + db + ' --batch --raw < ' + tmpFile + cleanup; } else if (type === 'pgsql') { var pHost = escapeSingleQuotedShellArg(cfg.host || '127.0.0.1'); var pPort = escapeSingleQuotedShellArg(cfg.port || '5432'); var pUser = escapeSingleQuotedShellArg(cfg.username || 'postgres'); var pPass = escapeSingleQuotedShellArg(cfg.password || ''); var pDb = escapeSingleQuotedShellArg(cfg.selectedDatabase || cfg.database || 'postgres'); command = decodeToFile + '; PGPASSWORD=' + pPass + ' psql -h ' + pHost + ' -p ' + pPort + ' -U ' + pUser + ' -d ' + pDb + ' -v ON_ERROR_STOP=1 -A -F "|" -P footer=off -f ' + tmpFile + cleanup; } else if (type === 'sqlite') { var sqlitePath = escapeSingleQuotedShellArg(cfg.sqlitePath || '/tmp/test.db'); command = decodeToFile + '; sqlite3 -header -separator "|" ' + sqlitePath + ' < ' + tmpFile + cleanup; } else if (type === 'mssql') { var sHost = cfg.host || '127.0.0.1'; var sPort = cfg.port || '1433'; var sUser = escapeSingleQuotedShellArg(cfg.username || 'sa'); var sPass = escapeSingleQuotedShellArg(cfg.password || ''); var sDb = escapeSingleQuotedShellArg(cfg.selectedDatabase || cfg.database || 'master'); var server = escapeSingleQuotedShellArg(sHost + ',' + sPort); command = decodeToFile + '; sqlcmd -S ' + server + ' -U ' + sUser + ' -P ' + sPass + ' -W -s "|" -d ' + sDb + ' -i ' + tmpFile + cleanup; } else { return { error: (wsT('webshell.dbExecFailed') || '数据库执行失败') + ': unsupported type ' + type }; } return { command: command }; } function buildWebshellDbSchemaCommand(cfg) { var type = cfg.type || 'mysql'; var schemaSQL = ''; if (type === 'mysql') { schemaSQL = "SELECT SCHEMA_NAME AS db_name, '' AS table_name, '' AS column_name FROM INFORMATION_SCHEMA.SCHEMATA UNION ALL SELECT TABLE_SCHEMA AS db_name, TABLE_NAME AS table_name, '' AS column_name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' UNION ALL SELECT TABLE_SCHEMA AS db_name, TABLE_NAME AS table_name, COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.COLUMNS ORDER BY db_name, table_name, column_name;"; } else if (type === 'pgsql') { schemaSQL = "SELECT table_schema AS db_name, table_name, '' AS column_name FROM information_schema.tables WHERE table_type='BASE TABLE' AND table_schema NOT IN ('pg_catalog','information_schema') UNION ALL SELECT table_schema AS db_name, table_name, column_name FROM information_schema.columns WHERE table_schema NOT IN ('pg_catalog','information_schema') ORDER BY db_name, table_name, column_name;"; } else if (type === 'sqlite') { schemaSQL = "SELECT 'main' AS db_name, name AS table_name, '' AS column_name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' UNION ALL SELECT 'main' AS db_name, m.name AS table_name, p.name AS column_name FROM sqlite_master m JOIN pragma_table_info(m.name) p ON 1=1 WHERE m.type='table' AND m.name NOT LIKE 'sqlite_%' ORDER BY db_name, table_name, column_name;"; } else if (type === 'mssql') { schemaSQL = "SELECT TABLE_SCHEMA AS db_name, TABLE_NAME AS table_name, '' AS column_name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' UNION ALL SELECT TABLE_SCHEMA AS db_name, TABLE_NAME AS table_name, COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.COLUMNS ORDER BY db_name, table_name, column_name;"; } else { return { error: (wsT('webshell.dbExecFailed') || '数据库执行失败') + ': unsupported type ' + type }; } return buildWebshellDbCommand(cfg, false, { sql: schemaSQL }); } function buildWebshellDbColumnsCommand(cfg, dbName, tableName) { var type = cfg.type || 'mysql'; var sql = ''; if (type === 'mysql') { sql = "SELECT COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=" + webshellDbQuoteLiteral(dbName) + " AND TABLE_NAME=" + webshellDbQuoteLiteral(tableName) + " ORDER BY ORDINAL_POSITION;"; } else if (type === 'pgsql') { sql = "SELECT column_name FROM information_schema.columns WHERE table_schema=" + webshellDbQuoteLiteral(dbName) + " AND table_name=" + webshellDbQuoteLiteral(tableName) + " ORDER BY ordinal_position;"; } else if (type === 'sqlite') { sql = "SELECT name AS column_name FROM pragma_table_info(" + webshellDbQuoteLiteral(tableName) + ") ORDER BY cid;"; } else if (type === 'mssql') { sql = "SELECT COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=" + webshellDbQuoteLiteral(dbName) + " AND TABLE_NAME=" + webshellDbQuoteLiteral(tableName) + " ORDER BY ORDINAL_POSITION;"; } else { return { error: (wsT('webshell.dbExecFailed') || '数据库执行失败') + ': unsupported type ' + type }; } return buildWebshellDbCommand(cfg, false, { sql: sql }); } function buildWebshellDbColumnsByDatabaseCommand(cfg, dbName) { var type = cfg.type || 'mysql'; var sql = ''; if (type === 'mysql') { sql = "SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=" + webshellDbQuoteLiteral(dbName) + " ORDER BY TABLE_NAME, ORDINAL_POSITION;"; } else if (type === 'pgsql') { sql = "SELECT table_name, column_name FROM information_schema.columns WHERE table_schema=" + webshellDbQuoteLiteral(dbName) + " ORDER BY table_name, ordinal_position;"; } else if (type === 'sqlite') { sql = "SELECT m.name AS table_name, p.name AS column_name FROM sqlite_master m JOIN pragma_table_info(m.name) p ON 1=1 WHERE m.type='table' AND m.name NOT LIKE 'sqlite_%' ORDER BY m.name, p.cid;"; } else if (type === 'mssql') { sql = "SELECT TABLE_NAME AS table_name, COLUMN_NAME AS column_name FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=" + webshellDbQuoteLiteral(dbName) + " ORDER BY TABLE_NAME, ORDINAL_POSITION;"; } else { return { error: (wsT('webshell.dbExecFailed') || '数据库执行失败') + ': unsupported type ' + type }; } return buildWebshellDbCommand(cfg, false, { sql: sql }); } function parseWebshellDbExecOutput(rawOutput) { var raw = String(rawOutput || ''); var rc = null; var cleaned = raw.replace(/__CSAI_DB_RC__:(\d+)\s*$/m, function (_, code) { rc = parseInt(code, 10); return ''; }).trim(); return { rc: rc, output: cleaned }; } function parseWebshellDbSchema(rawOutput) { var text = String(rawOutput || '').trim(); if (!text) return {}; var lines = text.split(/\r?\n/).filter(function (line) { return line && line.trim() && !/^\(\d+\s+rows?\)$/i.test(line.trim()) && !/^[-+\s|]+$/.test(line.trim()); }); if (lines.length < 2) return {}; var delimiter = lines[0].indexOf('\t') >= 0 ? '\t' : (lines[0].indexOf('|') >= 0 ? '|' : ''); if (!delimiter) return {}; var headers = lines[0].split(delimiter).map(function (s) { return String(s || '').trim().toLowerCase(); }); var dbIdx = headers.indexOf('db_name'); var tableIdx = headers.indexOf('table_name'); var columnIdx = headers.indexOf('column_name'); if (dbIdx < 0 || tableIdx < 0) return {}; var schema = {}; for (var i = 1; i < lines.length; i++) { var cols = lines[i].split(delimiter).map(function (s) { return String(s || '').trim(); }); if (cols.length !== headers.length) continue; var db = cols[dbIdx] || 'default'; var table = cols[tableIdx] || ''; var column = columnIdx >= 0 ? (cols[columnIdx] || '') : ''; if (!schema[db]) schema[db] = { tables: {} }; if (!table) continue; if (!schema[db].tables[table]) schema[db].tables[table] = []; if (column && schema[db].tables[table].indexOf(column) < 0) { schema[db].tables[table].push(column); } } return normalizeWebshellDbSchema(schema); } function parseWebshellDbColumns(rawOutput) { var text = String(rawOutput || '').trim(); if (!text) return []; var lines = text.split(/\r?\n/).filter(function (line) { return line && line.trim() && !/^\(\d+\s+rows?\)$/i.test(line.trim()) && !/^[-+\s|]+$/.test(line.trim()); }); if (lines.length < 2) return []; var delimiter = lines[0].indexOf('\t') >= 0 ? '\t' : (lines[0].indexOf('|') >= 0 ? '|' : ''); if (!delimiter) { if (String(lines[0] || '').trim().toLowerCase() !== 'column_name') return []; var plainColumns = []; for (var p = 1; p < lines.length; p++) { var plainName = String(lines[p] || '').trim(); if (!plainName || plainColumns.indexOf(plainName) >= 0) continue; plainColumns.push(plainName); } return plainColumns; } var headers = lines[0].split(delimiter).map(function (s) { return String(s || '').trim().toLowerCase(); }); var colIdx = headers.indexOf('column_name'); if (colIdx < 0) return []; var columns = []; for (var i = 1; i < lines.length; i++) { var cols = lines[i].split(delimiter).map(function (s) { return String(s || '').trim(); }); if (cols.length !== headers.length) continue; var name = cols[colIdx] || ''; if (!name || columns.indexOf(name) >= 0) continue; columns.push(name); } return columns; } function parseWebshellDbTableColumns(rawOutput) { var text = String(rawOutput || '').trim(); if (!text) return {}; var lines = text.split(/\r?\n/).filter(function (line) { return line && line.trim() && !/^\(\d+\s+rows?\)$/i.test(line.trim()) && !/^[-+\s|]+$/.test(line.trim()); }); if (lines.length < 2) return {}; var delimiter = lines[0].indexOf('\t') >= 0 ? '\t' : (lines[0].indexOf('|') >= 0 ? '|' : ''); if (!delimiter) return {}; var headers = lines[0].split(delimiter).map(function (s) { return String(s || '').trim().toLowerCase(); }); var tableIdx = headers.indexOf('table_name'); var colIdx = headers.indexOf('column_name'); if (tableIdx < 0 || colIdx < 0) return {}; var tableColumns = {}; for (var i = 1; i < lines.length; i++) { var cols = lines[i].split(delimiter).map(function (s) { return String(s || '').trim(); }); if (cols.length !== headers.length) continue; var tableName = cols[tableIdx] || ''; var colName = cols[colIdx] || ''; if (!tableName || !colName) continue; if (!tableColumns[tableName]) tableColumns[tableName] = []; if (tableColumns[tableName].indexOf(colName) >= 0) continue; tableColumns[tableName].push(colName); } return tableColumns; } function normalizeWebshellDbSchema(rawSchema) { if (!rawSchema || typeof rawSchema !== 'object') return {}; var normalized = {}; Object.keys(rawSchema).forEach(function (dbName) { var dbEntry = rawSchema[dbName]; var tableMap = {}; if (Array.isArray(dbEntry)) { dbEntry.forEach(function (tableName) { var t = String(tableName || '').trim(); if (!t) return; if (!tableMap[t]) tableMap[t] = []; }); } else if (dbEntry && typeof dbEntry === 'object') { var tablesSource = (dbEntry.tables && typeof dbEntry.tables === 'object') ? dbEntry.tables : dbEntry; Object.keys(tablesSource).forEach(function (tableName) { if (tableName === 'tables') return; var t = String(tableName || '').trim(); if (!t) return; var rawColumns = tablesSource[tableName]; var columns = Array.isArray(rawColumns) ? rawColumns : []; var uniqColumns = []; columns.forEach(function (colName) { var c = String(colName || '').trim(); if (!c || uniqColumns.indexOf(c) >= 0) return; uniqColumns.push(c); }); uniqColumns.sort(function (a, b) { return a.localeCompare(b); }); tableMap[t] = uniqColumns; }); } var sortedTables = {}; Object.keys(tableMap).sort(function (a, b) { return a.localeCompare(b); }).forEach(function (tableName) { sortedTables[tableName] = tableMap[tableName]; }); normalized[dbName] = { tables: sortedTables }; }); return normalized; } function simplifyWebshellAiError(rawMessage) { var msg = String(rawMessage || '').trim(); var lower = msg.toLowerCase(); if ((lower.indexOf('401') !== -1 || lower.indexOf('unauthorized') !== -1) && (lower.indexOf('api key') !== -1 || lower.indexOf('apikey') !== -1)) { return '鉴权失败:API Key 未配置或无效(401)'; } if (lower.indexOf('timeout') !== -1 || lower.indexOf('timed out') !== -1) { return '请求超时,请稍后重试'; } if (lower.indexOf('network') !== -1 || lower.indexOf('failed to fetch') !== -1) { return '网络异常,请检查服务连通性'; } return msg || '请求失败'; } function renderWebshellAiErrorMessage(targetEl, rawMessage) { if (!targetEl) return; var full = String(rawMessage || '').trim(); var shortMsg = simplifyWebshellAiError(full); targetEl.classList.add('webshell-ai-msg-error'); targetEl.innerHTML = ''; var head = document.createElement('div'); head.className = 'webshell-ai-error-head'; head.textContent = shortMsg; targetEl.appendChild(head); if (full && full !== shortMsg) { var detail = document.createElement('details'); detail.className = 'webshell-ai-error-detail'; var summary = document.createElement('summary'); summary.textContent = '查看详细错误'; var pre = document.createElement('pre'); pre.textContent = full; detail.appendChild(summary); detail.appendChild(pre); targetEl.appendChild(detail); } } function isLikelyWebshellAiErrorMessage(content, msg) { var text = String(content || '').trim(); if (!text) return false; var lower = text.toLowerCase(); if (/^(执行失败|请求失败|请求异常|error)\s*[::]/i.test(text)) return true; if (/(status code\s*:\s*4\d{2}|unauthorized|forbidden|apikey|api key|invalid api key)/i.test(lower)) return true; if (/(noderunerror|tool[-_ ]?error|agent[-_ ]?error|执行失败)/i.test(lower)) return true; var details = msg && Array.isArray(msg.processDetails) ? msg.processDetails : []; return details.some(function (d) { return String((d && d.eventType) || '').toLowerCase() === 'error'; }); } function formatWebshellAiConvDate(updatedAt) { if (!updatedAt) return ''; var d = typeof updatedAt === 'string' ? new Date(updatedAt) : updatedAt; if (isNaN(d.getTime())) return ''; var now = new Date(); var sameDay = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear(); if (sameDay) return d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0'); return (d.getMonth() + 1) + '/' + d.getDate(); } function webshellAgentPx(data) { if (!data || data.einoAgent == null) return ''; var s = String(data.einoAgent).trim(); return s ? ('[' + s + '] ') : ''; } // 根据后端保存的 processDetail 构建一条时间线项的 HTML(与 appendTimelineItem 展示一致) function buildWebshellTimelineItemFromDetail(detail) { var eventType = detail.eventType || ''; var title = detail.message || ''; var data = detail.data || {}; var ap = webshellAgentPx(data); if (eventType === 'iteration') { title = ap + ((typeof window.t === 'function') ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : ('第 ' + (data.iteration || 1) + ' 轮迭代')); } else if (eventType === 'thinking') { title = ap + '🤔 ' + ((typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考'); } else if (eventType === 'tool_calls_detected') { title = ap + '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : ('检测到 ' + (data.count || 0) + ' 个工具调用')); } else if (eventType === 'tool_call') { var tn = data.toolName || ((typeof window.t === 'function') ? window.t('chat.unknownTool') : '未知工具'); var idx = data.index || 0; var total = data.total || 0; title = ap + '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.callTool', { name: tn, index: idx, total: total }) : ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : ''))); } else if (eventType === 'tool_result') { var success = data.success !== false; var tname = data.toolName || '工具'; title = ap + (success ? '✅ ' : '❌ ') + ((typeof window.t === 'function') ? (success ? window.t('chat.toolExecComplete', { name: tname }) : window.t('chat.toolExecFailed', { name: tname })) : (tname + (success ? ' 执行完成' : ' 执行失败'))); } else if (eventType === 'eino_agent_reply') { title = ap + '💬 ' + ((typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复'); } else if (eventType === 'progress') { title = (typeof window.translateProgressMessage === 'function') ? window.translateProgressMessage(detail.message || '') : (detail.message || ''); } var html = '' + escapeHtml(title || '') + ''; if (eventType === 'eino_agent_reply' && detail.message) { html += '
' + escapeHtml(detail.message) + '
'; } if (eventType === 'tool_call' && data && (data.argumentsObj || data.arguments)) { try { var args = data.argumentsObj; if (args == null && data.arguments != null && String(data.arguments).trim() !== '') { try { args = JSON.parse(String(data.arguments)); } catch (e2) { args = { _raw: String(data.arguments) }; } } if (args && typeof args === 'object') { var paramsLabel = (typeof window.t === 'function') ? window.t('timeline.params') : '参数:'; html += '
' + escapeHtml(paramsLabel) + '
' + escapeHtml(JSON.stringify(args, null, 2)) + '
'; } } catch (e) {} } else if (eventType === 'tool_result' && data) { var isError = data.isError || data.success === false; var noResultText = (typeof window.t === 'function') ? window.t('timeline.noResult') : '无结果'; var result = data.result != null ? data.result : (data.error != null ? data.error : noResultText); var resultStr = (typeof result === 'string') ? result : JSON.stringify(result); var execResultLabel = (typeof window.t === 'function') ? window.t('timeline.executionResult') : '执行结果:'; var execIdLabel = (typeof window.t === 'function') ? window.t('timeline.executionId') : '执行ID:'; html += '
' + escapeHtml(execResultLabel) + '
' + escapeHtml(resultStr) + '
' + (data.executionId ? '
' + escapeHtml(execIdLabel) + ' ' + escapeHtml(String(data.executionId)) + '
' : '') + '
'; } else if (detail.message && detail.message !== title) { html += '
' + escapeHtml(detail.message) + '
'; } return html; } // 渲染「执行过程及调用工具」折叠块(默认折叠,刷新后加载历史时保留并可展开) function renderWebshellProcessDetailsBlock(processDetails, defaultCollapsed) { if (!processDetails || processDetails.length === 0) return null; var expandLabel = (typeof window.t === 'function') ? window.t('chat.expandDetail') : '展开详情'; var collapseLabel = (typeof window.t === 'function') ? window.t('tasks.collapseDetail') : '收起详情'; var headerLabel = (typeof window.t === 'function') ? (window.t('chat.penetrationTestDetail') || '执行过程及调用工具') : '执行过程及调用工具'; var wrapper = document.createElement('div'); wrapper.className = 'process-details-container webshell-ai-process-block'; var collapsed = defaultCollapsed !== false; wrapper.innerHTML = '
'; var timeline = wrapper.querySelector('.progress-timeline'); processDetails.forEach(function (d) { var item = document.createElement('div'); item.className = 'webshell-ai-timeline-item webshell-ai-timeline-' + (d.eventType || ''); item.innerHTML = buildWebshellTimelineItemFromDetail(d); timeline.appendChild(item); }); var toggleBtn = wrapper.querySelector('.webshell-ai-process-toggle'); var toggleIcon = wrapper.querySelector('.ws-toggle-icon'); toggleBtn.addEventListener('click', function () { var isExpanded = timeline.classList.contains('expanded'); timeline.classList.toggle('expanded'); toggleBtn.setAttribute('aria-expanded', !isExpanded); if (toggleIcon) toggleIcon.textContent = isExpanded ? '▶' : '▼'; }); return wrapper; } function fetchAndRenderWebshellAiConvList(conn, listEl) { if (!conn || !conn.id || !listEl || typeof apiFetch !== 'function') return Promise.resolve(); return apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id) + '/ai-conversations', { method: 'GET' }) .then(function (r) { return r.json(); }) .then(function (list) { if (!Array.isArray(list)) list = []; listEl.innerHTML = ''; list.forEach(function (item) { var row = document.createElement('div'); row.className = 'webshell-ai-conv-item'; row.dataset.convId = item.id; var title = (item.title || '').trim() || item.id.slice(0, 8); var dateStr = item.updatedAt ? formatWebshellAiConvDate(item.updatedAt) : ''; row.innerHTML = '' + escapeHtml(title) + '' + escapeHtml(dateStr) + ''; if (webshellAiConvMap[conn.id] === item.id) row.classList.add('active'); row.addEventListener('click', function () { webshellAiConvListSelect(conn, item.id, document.getElementById('webshell-ai-messages'), listEl); }); var delBtn = document.createElement('button'); delBtn.type = 'button'; delBtn.className = 'btn-ghost btn-sm webshell-ai-conv-del'; delBtn.textContent = '×'; delBtn.title = wsT('webshell.aiDeleteConversation') || '删除对话'; delBtn.addEventListener('click', function (e) { e.stopPropagation(); if (!confirm(wsT('webshell.aiDeleteConversationConfirm') || '确定删除该对话?')) return; var deletedId = item.id; apiFetch('/api/conversations/' + encodeURIComponent(deletedId), { method: 'DELETE' }) .then(function (r) { if (r.ok) { if (webshellAiConvMap[conn.id] === deletedId) { delete webshellAiConvMap[conn.id]; var msgs = document.getElementById('webshell-ai-messages'); if (msgs) msgs.innerHTML = ''; } fetchAndRenderWebshellAiConvList(conn, listEl); try { document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId: deletedId } })); } catch (err) { /* ignore */ } } }) .catch(function (e) { console.warn('删除对话失败', e); }); }); row.appendChild(delBtn); listEl.appendChild(row); }); }) .catch(function (e) { console.warn('加载对话列表失败', e); }); } function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) { if (!conn || !convId || !messagesContainer) return; webshellAiConvMap[conn.id] = convId; if (listEl) listEl.querySelectorAll('.webshell-ai-conv-item').forEach(function (el) { el.classList.toggle('active', el.dataset.convId === convId); }); if (typeof apiFetch !== 'function') return; apiFetch('/api/conversations/' + encodeURIComponent(convId), { method: 'GET' }) .then(function (r) { return r.json(); }) .then(function (data) { messagesContainer.innerHTML = ''; var list = data.messages || []; list.forEach(function (msg) { var role = (msg.role || '').toLowerCase(); var content = (msg.content || '').trim(); if (!content && role !== 'assistant') return; var div = document.createElement('div'); div.className = 'webshell-ai-msg ' + (role === 'user' ? 'user' : 'assistant'); if (role === 'user') { div.textContent = content; } else { if (isLikelyWebshellAiErrorMessage(content, msg)) { renderWebshellAiErrorMessage(div, content); } else if (typeof formatMarkdown === 'function') { div.innerHTML = formatMarkdown(content); } else { div.textContent = content; } } messagesContainer.appendChild(div); if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) { var block = renderWebshellProcessDetailsBlock(msg.processDetails, true); if (block) messagesContainer.appendChild(block); } }); if (list.length === 0) { var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; var readyDiv = document.createElement('div'); readyDiv.className = 'webshell-ai-msg assistant'; readyDiv.textContent = readyMsg; messagesContainer.appendChild(readyDiv); } messagesContainer.scrollTop = messagesContainer.scrollHeight; }) .catch(function (e) { console.warn('加载对话失败', e); }); } // 选择连接:渲染终端 + 文件管理 Tab,并初始化终端 function selectWebshell(id, stateReady) { currentWebshellId = id; renderWebshellList(); const conn = webshellConnections.find(c => c.id === id); const workspace = document.getElementById('webshell-workspace'); if (!workspace) return; if (!conn) { workspace.innerHTML = '
' + wsT('webshell.selectOrAdd') + '
'; return; } if (!stateReady) { ensureWebshellPersistStateLoaded(conn).then(function () { if (currentWebshellId === id) selectWebshell(id, true); }); return; } destroyWebshellTerminal(); webshellCurrentConn = conn; workspace.innerHTML = '
' + '' + '' + '' + '' + '' + '
' + '
' + '
' + ' ' + ' ' + '' + (wsT('webshell.terminalIdle') || '空闲') + ' ' + '' + (wsT('webshell.quickCommands') || '快捷命令') + ': ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '
' + '
' + '
' + '
' + '' + '' + '' + '' + '
' + '
' + '' + '
' + '' + (wsT('webshell.moreActions') || '更多操作') + '' + '
' + '' + '' + '' + '' + '' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '
' + '' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
' + '
' + '
' + '
' + '
' + (wsT('webshell.aiMemo') || '备忘录') + '
' + '' + '
' + (wsT('webshell.aiMemoSaved') || '已保存到本地') + '
' + '
' + '
' + '
' + '
' + '
' + '' + '
' + '
' + '' + '
' + '' + '' + '
' + '
' + (wsT('webshell.dbOutput') || '执行输出') + '
' + (wsT('webshell.dbCliHint') || '如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd)') + '
' + '' + '
' + '
' + '
'; // Tab 切换 workspace.querySelectorAll('.webshell-tab').forEach(btn => { btn.addEventListener('click', function () { const tab = btn.getAttribute('data-tab'); workspace.querySelectorAll('.webshell-tab').forEach(b => b.classList.remove('active')); workspace.querySelectorAll('.webshell-pane').forEach(p => p.classList.remove('active')); btn.classList.add('active'); const pane = document.getElementById('webshell-pane-' + tab); if (pane) pane.classList.add('active'); if (tab === 'terminal' && webshellTerminalInstance && webshellTerminalFitAddon) { try { webshellTerminalFitAddon.fit(); } catch (e) {} } }); }); // 文件管理:列出目录、上级目录 const pathInput = document.getElementById('webshell-file-path'); document.getElementById('webshell-list-dir').addEventListener('click', function () { // 点击时用当前连接,编辑保存后立即生效 webshellFileListDir(webshellCurrentConn, pathInput ? pathInput.value.trim() || '.' : '.'); }); document.getElementById('webshell-parent-dir').addEventListener('click', function () { const p = (pathInput && pathInput.value.trim()) || '.'; if (p === '.' || p === '/') { pathInput.value = '..'; } else { pathInput.value = p.replace(/\/[^/]+$/, '') || '.'; } webshellFileListDir(webshellCurrentConn, pathInput.value || '.'); }); // 清屏由 bindWebshellClearOnce 统一事件委托处理,此处不再绑定,避免重复绑定导致一次点击出现多个 shell> var terminalCopyLogBtn = document.getElementById('webshell-terminal-copy-log'); if (terminalCopyLogBtn) { terminalCopyLogBtn.addEventListener('click', function () { if (!webshellCurrentConn || !webshellCurrentConn.id) return; var activeId = getActiveWebshellTerminalSessionId(webshellCurrentConn.id); var log = getWebshellTerminalLog(getWebshellTerminalSessionKey(webshellCurrentConn.id, activeId)) || ''; if (navigator && navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(log).then(function () { terminalCopyLogBtn.title = wsT('webshell.terminalCopyOk') || '日志已复制'; setTimeout(function () { terminalCopyLogBtn.title = wsT('webshell.copyTerminalLog') || '复制日志'; }, 1200); }).catch(function () { terminalCopyLogBtn.title = wsT('webshell.terminalCopyFail') || '复制失败'; }); return; } try { var ta = document.createElement('textarea'); ta.value = log; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } catch (e) {} }); } renderWebshellTerminalSessions(conn); // 快捷命令:点击后执行并输出到终端 workspace.querySelectorAll('.webshell-quick-cmd').forEach(function (btn) { btn.addEventListener('click', function () { var cmd = btn.getAttribute('data-cmd'); if (cmd) runQuickCommand(cmd); }); }); // 文件:刷新、新建目录、新建文件、上传、批量操作 var filterInput = document.getElementById('webshell-file-filter'); document.getElementById('webshell-file-refresh').addEventListener('click', function () { webshellFileListDir(webshellCurrentConn, pathInput ? pathInput.value.trim() || '.' : '.'); }); if (filterInput) filterInput.addEventListener('input', function () { webshellFileListApplyFilter(); }); document.getElementById('webshell-mkdir-btn').addEventListener('click', function () { webshellFileMkdir(webshellCurrentConn, pathInput); }); document.getElementById('webshell-newfile-btn').addEventListener('click', function () { webshellFileNewFile(webshellCurrentConn, pathInput); }); document.getElementById('webshell-upload-btn').addEventListener('click', function () { webshellFileUpload(webshellCurrentConn, pathInput); }); document.getElementById('webshell-batch-delete-btn').addEventListener('click', function () { webshellBatchDelete(webshellCurrentConn, pathInput); }); document.getElementById('webshell-batch-download-btn').addEventListener('click', function () { webshellBatchDownload(webshellCurrentConn, pathInput); }); // AI 助手:侧边栏对话列表 + 主区消息 var aiInput = document.getElementById('webshell-ai-input'); var aiSendBtn = document.getElementById('webshell-ai-send'); var aiMessages = document.getElementById('webshell-ai-messages'); var aiNewConvBtn = document.getElementById('webshell-ai-new-conv'); var aiConvListEl = document.getElementById('webshell-ai-conv-list'); var aiMemoInput = document.getElementById('webshell-ai-memo-input'); var aiMemoStatus = document.getElementById('webshell-ai-memo-status'); var aiMemoClearBtn = document.getElementById('webshell-ai-memo-clear'); var aiMemoSaveTimer = null; function setWebshellAiMemoStatus(text, isError) { if (!aiMemoStatus) return; aiMemoStatus.textContent = text || ''; aiMemoStatus.classList.toggle('error', !!isError); } function flushWebshellAiMemo() { if (!aiMemoInput) return; saveWebshellAiMemo(conn, aiMemoInput.value || ''); setWebshellAiMemoStatus(wsT('webshell.aiMemoSaved') || '已保存到本地', false); } if (aiMemoInput) { aiMemoInput.value = getWebshellAiMemo(conn); setWebshellAiMemoStatus(wsT('webshell.aiMemoSaved') || '已保存到本地', false); aiMemoInput.addEventListener('input', function () { setWebshellAiMemoStatus(wsT('webshell.aiMemoSaving') || '保存中...', false); if (aiMemoSaveTimer) clearTimeout(aiMemoSaveTimer); aiMemoSaveTimer = setTimeout(function () { aiMemoSaveTimer = null; flushWebshellAiMemo(); }, 500); }); aiMemoInput.addEventListener('blur', function () { if (aiMemoSaveTimer) { clearTimeout(aiMemoSaveTimer); aiMemoSaveTimer = null; } flushWebshellAiMemo(); }); } if (aiMemoClearBtn && aiMemoInput) { aiMemoClearBtn.addEventListener('click', function () { aiMemoInput.value = ''; flushWebshellAiMemo(); aiMemoInput.focus(); }); } if (aiNewConvBtn) { aiNewConvBtn.addEventListener('click', function () { delete webshellAiConvMap[conn.id]; if (aiMessages) { aiMessages.innerHTML = ''; var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; var div = document.createElement('div'); div.className = 'webshell-ai-msg assistant'; div.textContent = readyMsg; aiMessages.appendChild(div); } if (aiConvListEl) aiConvListEl.querySelectorAll('.webshell-ai-conv-item').forEach(function (el) { el.classList.remove('active'); }); }); } if (aiSendBtn && aiInput && aiMessages) { aiSendBtn.addEventListener('click', function () { runWebshellAiSend(conn, aiInput, aiSendBtn, aiMessages); }); aiInput.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); runWebshellAiSend(conn, aiInput, aiSendBtn, aiMessages); } }); fetchAndRenderWebshellAiConvList(conn, aiConvListEl).then(function () { loadWebshellAiHistory(conn, aiMessages).then(function () { if (webshellAiConvMap[conn.id] && aiConvListEl) { aiConvListEl.querySelectorAll('.webshell-ai-conv-item').forEach(function (el) { el.classList.toggle('active', el.dataset.convId === webshellAiConvMap[conn.id]); }); } }); }); } // 数据库管理:支持多连接工作区(不同数据库类型并存)+ 刷新持久化 var dbTypeEl = document.getElementById('webshell-db-type'); var dbRunBtn = document.getElementById('webshell-db-run-btn'); var dbTestBtn = document.getElementById('webshell-db-test-btn'); var dbSqlEl = document.getElementById('webshell-db-sql'); var dbLoadSchemaBtn = document.getElementById('webshell-db-load-schema-btn'); var dbTemplateBtn = document.getElementById('webshell-db-template-btn'); var dbClearBtn = document.getElementById('webshell-db-clear-btn'); var dbSchemaTreeEl = document.getElementById('webshell-db-schema-tree'); var dbProfilesEl = document.getElementById('webshell-db-profiles'); var dbAddProfileBtn = document.getElementById('webshell-db-add-profile-btn'); var dbProfileModalEl = document.getElementById('webshell-db-profile-modal'); var dbProfileModalTitleEl = document.getElementById('webshell-db-profile-modal-title'); var dbProfileModalCloseBtn = document.getElementById('webshell-db-profile-modal-close'); var dbProfileModalCancelBtn = document.getElementById('webshell-db-profile-cancel-btn'); var dbProfileModalSaveBtn = document.getElementById('webshell-db-profile-save-btn'); var dbProfileNameEl = document.getElementById('webshell-db-profile-name'); var dbHostEl = document.getElementById('webshell-db-host'); var dbPortEl = document.getElementById('webshell-db-port'); var dbUserEl = document.getElementById('webshell-db-user'); var dbPassEl = document.getElementById('webshell-db-pass'); var dbNameEl = document.getElementById('webshell-db-name'); var dbSqliteEl = document.getElementById('webshell-db-sqlite-path'); var dbColumnsLoading = {}; var dbColumnsBatchLoading = {}; var dbColumnsBatchLoaded = {}; function resetDbColumnLoadCache() { dbColumnsLoading = {}; dbColumnsBatchLoading = {}; dbColumnsBatchLoaded = {}; } function setDbActionButtonsDisabled(disabled) { if (dbRunBtn) dbRunBtn.disabled = disabled; if (dbTestBtn) dbTestBtn.disabled = disabled; if (dbLoadSchemaBtn) dbLoadSchemaBtn.disabled = disabled; if (dbAddProfileBtn) dbAddProfileBtn.disabled = disabled; } function setDbProfileModalVisible(visible, mode) { if (!dbProfileModalEl) return; dbProfileModalEl.style.display = visible ? 'block' : 'none'; if (dbProfileModalTitleEl) { if (mode === 'add') dbProfileModalTitleEl.textContent = wsT('webshell.dbAddProfile') || '新增连接'; else dbProfileModalTitleEl.textContent = wsT('webshell.editConnectionTitle') || '编辑连接'; } } function applyActiveDbProfileToForm() { var dbCfg = getWebshellDbConfig(conn); if (!dbCfg) return; if (dbProfileNameEl) dbProfileNameEl.value = dbCfg.name || 'DB-1'; if (dbTypeEl) dbTypeEl.value = dbCfg.type || 'mysql'; if (dbHostEl) dbHostEl.value = dbCfg.host || '127.0.0.1'; if (dbPortEl) dbPortEl.value = dbCfg.port || ''; if (dbUserEl) dbUserEl.value = dbCfg.username || ''; if (dbPassEl) dbPassEl.value = dbCfg.password || ''; if (dbNameEl) dbNameEl.value = dbCfg.database || dbCfg.selectedDatabase || ''; if (dbSqliteEl) dbSqliteEl.value = dbCfg.sqlitePath || '/tmp/test.db'; if (dbSqlEl) dbSqlEl.value = dbCfg.sql || 'SELECT 1;'; webshellDbUpdateFieldVisibility(); webshellDbSetOutput(dbCfg.output || '', !!dbCfg.outputIsError); webshellDbRenderTable(dbCfg.output || ''); } function renderDbProfileTabs() { if (!dbProfilesEl) return; var state = getWebshellDbState(conn); var html = ''; state.profiles.forEach(function (p) { var active = p.id === state.activeProfileId; html += '
' + '' + '' + '' + '
'; }); dbProfilesEl.innerHTML = html; dbProfilesEl.querySelectorAll('[data-action]').forEach(function (btn) { btn.addEventListener('click', function () { var action = btn.getAttribute('data-action'); var id = btn.getAttribute('data-id') || ''; if (!id) return; var state = getWebshellDbState(conn); var idx = state.profiles.findIndex(function (p) { return p.id === id; }); if (idx < 0) return; if (action === 'switch') { state.activeProfileId = id; saveWebshellDbState(conn, state); applyActiveDbProfileToForm(); renderDbProfileTabs(); resetDbColumnLoadCache(); renderDbSchemaTree(); return; } if (action === 'edit') { state.activeProfileId = id; saveWebshellDbState(conn, state); applyActiveDbProfileToForm(); renderDbProfileTabs(); setDbProfileModalVisible(true, 'edit'); return; } if (action === 'delete') { if (state.profiles.length <= 1) return; if (!confirm(wsT('webshell.dbDeleteProfileConfirm') || '确定删除该数据库连接配置吗?')) return; state.profiles.splice(idx, 1); if (!state.profiles.some(function (p) { return p.id === state.activeProfileId; })) { state.activeProfileId = state.profiles[0].id; } saveWebshellDbState(conn, state); applyActiveDbProfileToForm(); renderDbProfileTabs(); resetDbColumnLoadCache(); renderDbSchemaTree(); } }); }); } function renderDbSchemaTree() { if (!dbSchemaTreeEl) return; var cfg = getWebshellDbConfig(conn); var schema = normalizeWebshellDbSchema((cfg && cfg.schema && typeof cfg.schema === 'object') ? cfg.schema : {}); var dbs = Object.keys(schema).sort(function (a, b) { return a.localeCompare(b); }); var openTableKeys = {}; dbSchemaTreeEl.querySelectorAll('.webshell-db-table-node[open]').forEach(function (node) { var openDb = node.getAttribute('data-db') || ''; var openTable = node.getAttribute('data-table') || ''; if (!openDb || !openTable) return; openTableKeys[openDb + '::' + openTable] = true; }); if (!dbs.length) { dbSchemaTreeEl.innerHTML = '
' + escapeHtml(wsT('webshell.dbNoSchema') || '暂无数据库结构,请先加载') + '
'; return; } var selectedDb = (cfg.selectedDatabase || '').trim(); var html = ''; dbs.forEach(function (dbName) { var tables = (schema[dbName] && schema[dbName].tables) ? schema[dbName].tables : {}; var tableNames = Object.keys(tables).sort(function (a, b) { return a.localeCompare(b); }); var isActive = selectedDb && selectedDb === dbName; html += '
'; html += '🗄' + escapeHtml(dbName) + '' + tableNames.length + ''; html += '
'; tableNames.forEach(function (tableName) { var columns = Array.isArray(tables[tableName]) ? tables[tableName] : []; var columnCountText = columns.length > 0 ? String(columns.length) : '-'; var tableKey = dbName + '::' + tableName; var tableOpen = !!openTableKeys[tableKey]; html += '
'; html += '📄' + escapeHtml(tableName) + '' + escapeHtml(columnCountText) + ''; if (columns.length) { html += '
'; columns.forEach(function (columnName) { html += ''; }); html += '
'; } else { html += '
' + escapeHtml(wsT('webshell.dbNoColumns') || '暂无列信息') + '
'; } html += '
'; }); html += '
'; }); dbSchemaTreeEl.innerHTML = html; dbSchemaTreeEl.querySelectorAll('.webshell-db-group-title').forEach(function (el) { el.addEventListener('click', function () { var cfg = webshellDbCollectConfig(conn); cfg.selectedDatabase = el.getAttribute('data-db') || ''; saveWebshellDbConfig(conn, cfg); if (dbNameEl && cfg.type !== 'sqlite') dbNameEl.value = cfg.selectedDatabase; ensureDbDatabaseColumns(cfg.selectedDatabase); }); }); dbSchemaTreeEl.querySelectorAll('.webshell-db-table-item').forEach(function (el) { el.addEventListener('click', function () { var table = el.getAttribute('data-table') || ''; var dbName = el.getAttribute('data-db') || ''; if (!table) return; var cfg = webshellDbCollectConfig(conn); cfg.selectedDatabase = dbName; if (cfg.type !== 'sqlite') cfg.database = dbName; saveWebshellDbConfig(conn, cfg); if (dbNameEl && cfg.type !== 'sqlite') dbNameEl.value = dbName; var tableRef = cfg.type === 'sqlite' ? webshellDbQuoteIdentifier(cfg.type, table) : webshellDbQuoteIdentifier(cfg.type, dbName) + '.' + webshellDbQuoteIdentifier(cfg.type, table); if (dbSqlEl) { dbSqlEl.value = 'SELECT * FROM ' + tableRef + ' ORDER BY 1 DESC LIMIT 20;'; webshellDbCollectConfig(conn); } ensureDbTableColumns(dbName, table); }); }); dbSchemaTreeEl.querySelectorAll('.webshell-db-table-node').forEach(function (node) { node.addEventListener('toggle', function () { if (!node.open) return; var dbName = node.getAttribute('data-db') || ''; var table = node.getAttribute('data-table') || ''; if (!dbName || !table) return; ensureDbTableColumns(dbName, table); }); }); dbSchemaTreeEl.querySelectorAll('.webshell-db-column-item').forEach(function (el) { el.addEventListener('click', function (evt) { evt.preventDefault(); evt.stopPropagation(); var table = el.getAttribute('data-table') || ''; var column = el.getAttribute('data-column') || ''; var dbName = el.getAttribute('data-db') || ''; if (!table || !column) return; var cfg = webshellDbCollectConfig(conn); cfg.selectedDatabase = dbName; if (cfg.type !== 'sqlite') cfg.database = dbName; saveWebshellDbConfig(conn, cfg); if (dbNameEl && cfg.type !== 'sqlite') dbNameEl.value = dbName; var tableRef = cfg.type === 'sqlite' ? webshellDbQuoteIdentifier(cfg.type, table) : webshellDbQuoteIdentifier(cfg.type, dbName) + '.' + webshellDbQuoteIdentifier(cfg.type, table); if (dbSqlEl) { dbSqlEl.value = 'SELECT ' + webshellDbQuoteIdentifier(cfg.type, column) + ' FROM ' + tableRef + ' LIMIT 20;'; webshellDbCollectConfig(conn); } }); }); var autoDb = selectedDb || dbs[0] || ''; if (autoDb) ensureDbDatabaseColumns(autoDb); } function ensureDbTableColumns(dbName, tableName) { if (!dbName || !tableName || webshellRunning) return; var loadKey = (conn && conn.id ? conn.id : 'local') + '::' + dbName + '::' + tableName; if (dbColumnsLoading[loadKey]) return; webshellDbCollectConfig(conn); var cfg = getWebshellDbConfig(conn); var schema = normalizeWebshellDbSchema((cfg && cfg.schema && typeof cfg.schema === 'object') ? cfg.schema : {}); if (!schema[dbName]) return; if (!schema[dbName].tables[tableName]) return; if (Array.isArray(schema[dbName].tables[tableName]) && schema[dbName].tables[tableName].length > 0) return; var built = buildWebshellDbColumnsCommand(cfg, dbName, tableName); if (!built.command) return; dbColumnsLoading[loadKey] = true; webshellRunning = true; setDbActionButtonsDisabled(true); execWebshellCommand(conn, built.command).then(function (out) { var parsed = parseWebshellDbExecOutput(out); var success = parsed.rc === 0 || (parsed.rc == null && parsed.output && !/error|failed|denied|unknown|not found|access/i.test(parsed.output)); if (!success) return; var columns = parseWebshellDbColumns(parsed.output); if (!columns.length) return; var nextCfg = getWebshellDbConfig(conn); var nextSchema = normalizeWebshellDbSchema((nextCfg && nextCfg.schema && typeof nextCfg.schema === 'object') ? nextCfg.schema : {}); if (!nextSchema[dbName]) nextSchema[dbName] = { tables: {} }; if (!nextSchema[dbName].tables[tableName]) nextSchema[dbName].tables[tableName] = []; nextSchema[dbName].tables[tableName] = columns; nextCfg.schema = nextSchema; saveWebshellDbConfig(conn, nextCfg); renderDbSchemaTree(); }).catch(function () { // ignore single-table column load errors to avoid interrupting main flow }).finally(function () { delete dbColumnsLoading[loadKey]; webshellRunning = false; setDbActionButtonsDisabled(false); }); } function ensureDbDatabaseColumns(dbName) { if (!dbName || webshellRunning) return; webshellDbCollectConfig(conn); var cfg = getWebshellDbConfig(conn); var schema = normalizeWebshellDbSchema((cfg && cfg.schema && typeof cfg.schema === 'object') ? cfg.schema : {}); if (!schema[dbName] || !schema[dbName].tables) return; var hasUnknown = Object.keys(schema[dbName].tables).some(function (tableName) { var cols = schema[dbName].tables[tableName]; return !Array.isArray(cols) || cols.length === 0; }); if (!hasUnknown) return; var batchKey = (conn && conn.id ? conn.id : 'local') + '::' + (cfg.type || 'mysql') + '::' + dbName; if (dbColumnsBatchLoading[batchKey] || dbColumnsBatchLoaded[batchKey]) return; var built = buildWebshellDbColumnsByDatabaseCommand(cfg, dbName); if (!built.command) return; dbColumnsBatchLoading[batchKey] = true; webshellRunning = true; setDbActionButtonsDisabled(true); execWebshellCommand(conn, built.command).then(function (out) { var parsed = parseWebshellDbExecOutput(out); var success = parsed.rc === 0 || (parsed.rc == null && parsed.output && !/error|failed|denied|unknown|not found|access/i.test(parsed.output)); if (!success) return; var tableColumns = parseWebshellDbTableColumns(parsed.output); if (!Object.keys(tableColumns).length) return; var nextCfg = getWebshellDbConfig(conn); var nextSchema = normalizeWebshellDbSchema((nextCfg && nextCfg.schema && typeof nextCfg.schema === 'object') ? nextCfg.schema : {}); if (!nextSchema[dbName]) nextSchema[dbName] = { tables: {} }; Object.keys(tableColumns).forEach(function (tableName) { nextSchema[dbName].tables[tableName] = tableColumns[tableName]; }); nextCfg.schema = nextSchema; saveWebshellDbConfig(conn, nextCfg); renderDbSchemaTree(); }).catch(function () { // ignore batch column load errors to avoid interrupting main flow }).finally(function () { delete dbColumnsBatchLoading[batchKey]; dbColumnsBatchLoaded[batchKey] = true; webshellRunning = false; setDbActionButtonsDisabled(false); }); } function loadDbSchema() { if (!conn || !conn.id) { webshellDbSetOutput(wsT('webshell.dbNoConn') || '请先选择 WebShell 连接', true); return; } if (webshellRunning) { webshellDbSetOutput(wsT('webshell.dbRunning') || '数据库命令执行中,请稍候', true); return; } var cfg = webshellDbCollectConfig(conn); resetDbColumnLoadCache(); var built = buildWebshellDbSchemaCommand(cfg); if (!built.command) { webshellDbSetOutput(built.error || (wsT('webshell.dbSchemaFailed') || '加载数据库结构失败'), true); return; } webshellDbSetOutput(wsT('webshell.running') || '执行中…', false); webshellRunning = true; setDbActionButtonsDisabled(true); execWebshellCommand(conn, built.command).then(function (out) { var parsed = parseWebshellDbExecOutput(out); var success = parsed.rc === 0 || (parsed.rc == null && parsed.output && !/error|failed|denied|unknown|not found|access/i.test(parsed.output)); if (!success) { webshellDbSetOutput((wsT('webshell.dbSchemaFailed') || '加载数据库结构失败') + ':\n' + (parsed.output || ''), true); return; } cfg.schema = parseWebshellDbSchema(parsed.output); cfg.output = wsT('webshell.dbSchemaLoaded') || '结构加载完成'; cfg.outputIsError = false; saveWebshellDbConfig(conn, cfg); renderDbSchemaTree(); webshellDbSetOutput(wsT('webshell.dbSchemaLoaded') || '结构加载完成', false); }).catch(function (err) { webshellDbSetOutput((wsT('webshell.dbSchemaFailed') || '加载数据库结构失败') + ': ' + (err && err.message ? err.message : String(err)), true); }).finally(function () { webshellRunning = false; setDbActionButtonsDisabled(false); }); } function runDbQuery(isTestOnly) { if (!conn || !conn.id) { webshellDbSetOutput(wsT('webshell.dbNoConn') || '请先选择 WebShell 连接', true); return; } if (webshellRunning) { webshellDbSetOutput(wsT('webshell.dbRunning') || '数据库命令执行中,请稍候', true); return; } var cfg = webshellDbCollectConfig(conn); var built = buildWebshellDbCommand(cfg, !!isTestOnly); if (!built.command) { webshellDbSetOutput(built.error || (wsT('webshell.dbExecFailed') || '数据库执行失败'), true); return; } webshellDbSetOutput(wsT('webshell.running') || '执行中…', false); webshellRunning = true; setDbActionButtonsDisabled(true); execWebshellCommand(conn, built.command).then(function (out) { var parsed = parseWebshellDbExecOutput(out); var code = parsed.rc; var content = parsed.output || ''; var success = (code === 0) || (code == null && content && !/error|failed|denied|unknown|not found|access/i.test(content)); if (isTestOnly) { if (success) { cfg.output = '连接测试通过'; cfg.outputIsError = false; saveWebshellDbConfig(conn, cfg); webshellDbSetOutput(cfg.output, false); } else { cfg.output = '连接测试失败' + (content ? (':\n' + content) : ''); cfg.outputIsError = true; saveWebshellDbConfig(conn, cfg); webshellDbSetOutput(cfg.output, true); } return; } if (!success) { cfg.output = (wsT('webshell.dbExecFailed') || '数据库执行失败') + (content ? (':\n' + content) : ''); cfg.outputIsError = true; saveWebshellDbConfig(conn, cfg); webshellDbSetOutput(cfg.output, true); return; } var hasTable = webshellDbRenderTable(content); if (hasTable) { cfg.output = wsT('webshell.dbExecSuccess') || 'SQL 执行成功'; cfg.outputIsError = false; saveWebshellDbConfig(conn, cfg); webshellDbSetOutput(cfg.output, false); } else { cfg.output = content || (wsT('webshell.dbNoOutput') || '执行完成(无输出)'); cfg.outputIsError = false; saveWebshellDbConfig(conn, cfg); webshellDbSetOutput(cfg.output, false); } }).catch(function (err) { cfg.output = (wsT('webshell.dbExecFailed') || '数据库执行失败') + ': ' + (err && err.message ? err.message : String(err)); cfg.outputIsError = true; saveWebshellDbConfig(conn, cfg); webshellDbSetOutput(cfg.output, true); }).finally(function () { webshellRunning = false; setDbActionButtonsDisabled(false); }); } if (dbTypeEl) dbTypeEl.addEventListener('change', function () { webshellDbUpdateFieldVisibility(); var cfg = webshellDbCollectConfig(conn); cfg.selectedDatabase = ''; cfg.schema = {}; saveWebshellDbConfig(conn, cfg); resetDbColumnLoadCache(); renderDbSchemaTree(); }); ['webshell-db-profile-name', 'webshell-db-host', 'webshell-db-port', 'webshell-db-user', 'webshell-db-pass', 'webshell-db-name', 'webshell-db-sqlite-path'].forEach(function (id) { var el = document.getElementById(id); if (el) el.addEventListener('change', function () { webshellDbCollectConfig(conn); resetDbColumnLoadCache(); renderDbProfileTabs(); }); }); if (dbSqlEl) dbSqlEl.addEventListener('change', function () { webshellDbCollectConfig(conn); }); if (dbRunBtn) dbRunBtn.addEventListener('click', function () { runDbQuery(false); }); if (dbTestBtn) dbTestBtn.addEventListener('click', function () { runDbQuery(true); }); if (dbLoadSchemaBtn) dbLoadSchemaBtn.addEventListener('click', function () { loadDbSchema(); }); if (dbTemplateBtn) dbTemplateBtn.addEventListener('click', function () { if (!dbSqlEl) return; var cfg = webshellDbCollectConfig(conn); if (cfg.type === 'mysql') dbSqlEl.value = 'SHOW DATABASES;\nSELECT DATABASE() AS current_db;'; else if (cfg.type === 'pgsql') dbSqlEl.value = 'SELECT current_database();\nSELECT schema_name FROM information_schema.schemata ORDER BY schema_name;'; else if (cfg.type === 'sqlite') dbSqlEl.value = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"; else dbSqlEl.value = "SELECT name FROM sys.databases ORDER BY name;\nSELECT DB_NAME() AS current_db;"; webshellDbCollectConfig(conn); }); if (dbClearBtn) dbClearBtn.addEventListener('click', function () { if (dbSqlEl) dbSqlEl.value = ''; webshellDbCollectConfig(conn); }); if (dbProfileModalCloseBtn) dbProfileModalCloseBtn.addEventListener('click', function () { setDbProfileModalVisible(false); }); if (dbProfileModalCancelBtn) dbProfileModalCancelBtn.addEventListener('click', function () { applyActiveDbProfileToForm(); setDbProfileModalVisible(false); }); if (dbProfileModalSaveBtn) dbProfileModalSaveBtn.addEventListener('click', function () { webshellDbCollectConfig(conn); renderDbProfileTabs(); resetDbColumnLoadCache(); setDbProfileModalVisible(false); }); if (dbProfileModalEl) dbProfileModalEl.addEventListener('click', function (evt) { if (evt.target === dbProfileModalEl) { applyActiveDbProfileToForm(); setDbProfileModalVisible(false); } }); if (dbAddProfileBtn) dbAddProfileBtn.addEventListener('click', function () { var state = getWebshellDbState(conn); var name = 'DB-' + (state.profiles.length + 1); var p = newWebshellDbProfile(name); state.profiles.push(p); state.activeProfileId = p.id; saveWebshellDbState(conn, state); applyActiveDbProfileToForm(); renderDbProfileTabs(); renderDbSchemaTree(); setDbProfileModalVisible(true, 'add'); }); renderDbProfileTabs(); applyActiveDbProfileToForm(); renderDbSchemaTree(); setDbProfileModalVisible(false); initWebshellTerminal(conn); } // 加载 WebShell 连接的 AI 助手对话历史(持久化展示),返回 Promise 供 .then 更新工具栏等;含 processDetails 时渲染折叠的「执行过程及调用工具」 function loadWebshellAiHistory(conn, messagesContainer) { if (!conn || !conn.id || !messagesContainer) return Promise.resolve(); if (typeof apiFetch !== 'function') return Promise.resolve(); return apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id) + '/ai-history', { method: 'GET' }) .then(function (r) { return r.json(); }) .then(function (data) { if (data.conversationId) webshellAiConvMap[conn.id] = data.conversationId; var list = Array.isArray(data.messages) ? data.messages : []; list.forEach(function (msg) { var role = (msg.role || '').toLowerCase(); var content = (msg.content || '').trim(); if (!content && role !== 'assistant') return; var div = document.createElement('div'); div.className = 'webshell-ai-msg ' + (role === 'user' ? 'user' : 'assistant'); if (role === 'user') { div.textContent = content; } else { if (isLikelyWebshellAiErrorMessage(content, msg)) { renderWebshellAiErrorMessage(div, content); } else if (typeof formatMarkdown === 'function') { div.innerHTML = formatMarkdown(content); } else { div.textContent = content; } } messagesContainer.appendChild(div); if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) { var block = renderWebshellProcessDetailsBlock(msg.processDetails, true); if (block) messagesContainer.appendChild(block); } }); if (list.length === 0) { var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; var readyDiv = document.createElement('div'); readyDiv.className = 'webshell-ai-msg assistant'; readyDiv.textContent = readyMsg; messagesContainer.appendChild(readyDiv); } messagesContainer.scrollTop = messagesContainer.scrollHeight; }) .catch(function (e) { console.warn('加载 WebShell AI 历史失败', conn.id, e); }); } function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) { if (!conn || !conn.id) return; var message = (inputEl && inputEl.value || '').trim(); if (!message) return; if (webshellAiSending) return; if (typeof apiFetch !== 'function') { if (messagesContainer) { var errDiv = document.createElement('div'); errDiv.className = 'webshell-ai-msg assistant'; errDiv.textContent = '无法发送:未登录或 apiFetch 不可用'; messagesContainer.appendChild(errDiv); messagesContainer.scrollTop = messagesContainer.scrollHeight; } return; } webshellAiSending = true; if (sendBtn) sendBtn.disabled = true; var userDiv = document.createElement('div'); userDiv.className = 'webshell-ai-msg user'; userDiv.textContent = message; messagesContainer.appendChild(userDiv); var timelineContainer = document.createElement('div'); timelineContainer.className = 'webshell-ai-timeline'; timelineContainer.setAttribute('aria-live', 'polite'); var assistantDiv = document.createElement('div'); assistantDiv.className = 'webshell-ai-msg assistant'; assistantDiv.textContent = '…'; messagesContainer.appendChild(timelineContainer); messagesContainer.appendChild(assistantDiv); messagesContainer.scrollTop = messagesContainer.scrollHeight; function appendTimelineItem(type, title, message, data) { var item = document.createElement('div'); item.className = 'webshell-ai-timeline-item webshell-ai-timeline-' + type; var html = '' + escapeHtml(title || message || '') + ''; // 工具调用入参 if (type === 'tool_call' && data) { try { var args = data.argumentsObj; if (args == null && data.arguments != null && String(data.arguments).trim() !== '') { try { args = JSON.parse(String(data.arguments)); } catch (e1) { args = { _raw: String(data.arguments) }; } } if (args && typeof args === 'object') { var paramsLabel = (typeof window.t === 'function') ? window.t('timeline.params') : '参数:'; html += '
' + escapeHtml(paramsLabel) + '
' +
                        escapeHtml(JSON.stringify(args, null, 2)) +
                        '
'; } } catch (e) { // JSON 解析失败时忽略参数详情,避免打断主流程 } } else if (type === 'eino_agent_reply' && message) { html += '
' + escapeHtml(message) + '
'; } else if (type === 'tool_result' && data) { // 工具调用出参 var isError = data.isError || data.success === false; var noResultText = (typeof window.t === 'function') ? window.t('timeline.noResult') : '无结果'; var result = data.result != null ? data.result : (data.error != null ? data.error : noResultText); var resultStr = (typeof result === 'string') ? result : JSON.stringify(result); var execResultLabel = (typeof window.t === 'function') ? window.t('timeline.executionResult') : '执行结果:'; var execIdLabel = (typeof window.t === 'function') ? window.t('timeline.executionId') : '执行ID:'; html += '
' + escapeHtml(execResultLabel) + '
' +
                escapeHtml(resultStr) +
                '
' + (data.executionId ? '
' + escapeHtml(execIdLabel) + ' ' + escapeHtml(String(data.executionId)) + '
' : '') + '
'; } else if (message && message !== title) { html += '
' + escapeHtml(message) + '
'; } item.innerHTML = html; timelineContainer.appendChild(item); timelineContainer.classList.add('has-items'); messagesContainer.scrollTop = messagesContainer.scrollHeight; return item; } var einoSubReplyStreams = new Map(); if (inputEl) inputEl.value = ''; var convId = webshellAiConvMap[conn.id] || ''; var body = { message: message, webshellConnectionId: conn.id, conversationId: convId }; // 流式输出:支持 progress 实时更新、response 打字机效果;若后端发送多段 response 则追加 var streamingTarget = ''; // 当前要打字显示的目标全文(用于打字机效果) var streamingTypingId = 0; // 防重入,每次新 response 自增 resolveWebshellAiStreamPath().then(function (streamPath) { return apiFetch(streamPath, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); }).then(function (response) { if (!response.ok) { renderWebshellAiErrorMessage(assistantDiv, '请求失败: HTTP ' + response.status); return; } return response.body.getReader(); }).then(function (reader) { if (!reader) return; var decoder = new TextDecoder(); var buffer = ''; return reader.read().then(function processChunk(result) { if (result.done) return; buffer += decoder.decode(result.value, { stream: true }); var lines = buffer.split('\n'); buffer = lines.pop() || ''; for (var i = 0; i < lines.length; i++) { var line = lines[i]; if (line.indexOf('data: ') !== 0) continue; try { var eventData = JSON.parse(line.slice(6)); if (eventData.type === 'conversation' && eventData.data && eventData.data.conversationId) { // 先把 conversationId 拿出来,避免后续异步回调里 eventData 被后续事件覆盖导致 undefined 报错 var convId = eventData.data.conversationId; webshellAiConvMap[conn.id] = convId; var listEl = document.getElementById('webshell-ai-conv-list'); if (listEl) fetchAndRenderWebshellAiConvList(conn, listEl).then(function () { listEl.querySelectorAll('.webshell-ai-conv-item').forEach(function (el) { el.classList.toggle('active', el.dataset.convId === convId); }); }); } else if (eventData.type === 'response_start') { streamingTarget = ''; webshellStreamingTypingId += 1; streamingTypingId = webshellStreamingTypingId; assistantDiv.textContent = '…'; messagesContainer.scrollTop = messagesContainer.scrollHeight; } else if (eventData.type === 'response_delta') { var deltaText = (eventData.message != null && eventData.message !== '') ? String(eventData.message) : ''; if (deltaText) { streamingTarget += deltaText; webshellStreamingTypingId += 1; streamingTypingId = webshellStreamingTypingId; runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer); } } else if (eventData.type === 'response') { var text = (eventData.message != null && eventData.message !== '') ? eventData.message : (eventData.data && typeof eventData.data === 'string' ? eventData.data : ''); if (text) { // response 为最终完整内容:避免与增量重复拼接 streamingTarget = String(text); webshellStreamingTypingId += 1; streamingTypingId = webshellStreamingTypingId; runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer); } } else if (eventData.type === 'error' && eventData.message) { streamingTypingId += 1; var errLabel = (typeof window.t === 'function') ? window.t('chat.error') : '错误'; appendTimelineItem('error', '❌ ' + errLabel, eventData.message, eventData.data); renderWebshellAiErrorMessage(assistantDiv, errLabel + ': ' + eventData.message); } else if (eventData.type === 'progress' && eventData.message) { var progressMsg = (typeof window.translateProgressMessage === 'function') ? window.translateProgressMessage(eventData.message) : eventData.message; appendTimelineItem('progress', '🔍 ' + progressMsg, '', eventData.data); if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'iteration') { var iterN = (eventData.data && eventData.data.iteration) || 0; var iterTitle = (typeof window.t === 'function') ? window.t('chat.iterationRound', { n: iterN || 1 }) : (iterN ? ('第 ' + iterN + ' 轮迭代') : (eventData.message || '迭代')); var iterMessage = eventData.message || ''; if (iterMessage && typeof window.translateProgressMessage === 'function') { iterMessage = window.translateProgressMessage(iterMessage); } appendTimelineItem('iteration', '🔍 ' + iterTitle, iterMessage, eventData.data); if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'thinking' && eventData.message) { var thinkLabel = (typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考'; var thinkD = eventData.data || {}; appendTimelineItem('thinking', webshellAgentPx(thinkD) + '🤔 ' + thinkLabel, eventData.message, thinkD); if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'tool_calls_detected' && eventData.data) { var count = eventData.data.count || 0; var detectedLabel = (typeof window.t === 'function') ? window.t('chat.toolCallsDetected', { count: count }) : ('检测到 ' + count + ' 个工具调用'); appendTimelineItem('tool_calls_detected', webshellAgentPx(eventData.data) + '🔧 ' + detectedLabel, eventData.message || '', eventData.data); if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'tool_call' && eventData.data) { var d = eventData.data; var tn = d.toolName || '未知工具'; var idx = d.index || 0; var total = d.total || 0; var callTitle = (typeof window.t === 'function') ? window.t('chat.callTool', { name: tn, index: idx, total: total }) : ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '')); var title = webshellAgentPx(d) + '🔧 ' + callTitle; appendTimelineItem('tool_call', title, eventData.message || '', eventData.data); if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'tool_result' && eventData.data) { var dr = eventData.data; var success = dr.success !== false; var tname = dr.toolName || '工具'; var titleText = (typeof window.t === 'function') ? (success ? window.t('chat.toolExecComplete', { name: tname }) : window.t('chat.toolExecFailed', { name: tname })) : (tname + (success ? ' 执行完成' : ' 执行失败')); var title = webshellAgentPx(dr) + (success ? '✅ ' : '❌ ') + titleText; var sub = eventData.message || (dr.result ? String(dr.result).slice(0, 300) : ''); appendTimelineItem('tool_result', title, sub, eventData.data); if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'eino_agent_reply_stream_start' && eventData.data && eventData.data.streamId) { var rdS = eventData.data; var repTS = (typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复'; var runTS = (typeof window.t === 'function') ? window.t('timeline.running') : '执行中...'; var itemS = document.createElement('div'); itemS.className = 'webshell-ai-timeline-item webshell-ai-timeline-eino_agent_reply'; itemS.innerHTML = '' + escapeHtml(webshellAgentPx(rdS) + '💬 ' + repTS + ' · ' + runTS) + ''; timelineContainer.appendChild(itemS); timelineContainer.classList.add('has-items'); einoSubReplyStreams.set(rdS.streamId, { el: itemS, buf: '' }); if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'eino_agent_reply_stream_delta' && eventData.data && eventData.data.streamId) { var stD = einoSubReplyStreams.get(eventData.data.streamId); if (stD) { stD.buf += (eventData.message || ''); var preD = stD.el.querySelector('.webshell-eino-reply-stream-body'); if (!preD) { preD = document.createElement('pre'); preD.className = 'webshell-ai-timeline-msg webshell-eino-reply-stream-body'; preD.style.whiteSpace = 'pre-wrap'; stD.el.appendChild(preD); } preD.textContent = stD.buf; } if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'eino_agent_reply_stream_end' && eventData.data && eventData.data.streamId) { var stE = einoSubReplyStreams.get(eventData.data.streamId); if (stE) { var fullE = (eventData.message != null && eventData.message !== '') ? String(eventData.message) : stE.buf; stE.buf = fullE; var repTE = (typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复'; var titE = stE.el.querySelector('.webshell-ai-timeline-title'); if (titE) titE.textContent = webshellAgentPx(eventData.data) + '💬 ' + repTE; var preE = stE.el.querySelector('.webshell-eino-reply-stream-body'); if (!preE) { preE = document.createElement('pre'); preE.className = 'webshell-ai-timeline-msg webshell-eino-reply-stream-body'; preE.style.whiteSpace = 'pre-wrap'; stE.el.appendChild(preE); } preE.textContent = fullE; einoSubReplyStreams.delete(eventData.data.streamId); } if (!streamingTarget) assistantDiv.textContent = '…'; } else if (eventData.type === 'eino_agent_reply' && eventData.message) { var rd = eventData.data || {}; var replyT = (typeof window.t === 'function') ? window.t('chat.einoAgentReplyTitle') : '子代理回复'; appendTimelineItem('eino_agent_reply', webshellAgentPx(rd) + '💬 ' + replyT, eventData.message, rd); if (!streamingTarget) assistantDiv.textContent = '…'; } } catch (e) { /* ignore parse error */ } } messagesContainer.scrollTop = messagesContainer.scrollHeight; return reader.read().then(processChunk); }); }).catch(function (err) { renderWebshellAiErrorMessage(assistantDiv, '请求异常: ' + (err && err.message ? err.message : String(err))); }).then(function () { webshellAiSending = false; if (sendBtn) sendBtn.disabled = false; if (assistantDiv.textContent === '…' && !streamingTarget) { // 没有任何 response 内容,保持纯文本提示 assistantDiv.textContent = '无回复内容'; } else if (streamingTarget) { // 流式结束:先终止当前打字机循环,避免后续 tick 把 HTML 覆盖回纯文本 webshellStreamingTypingId += 1; // 再使用 Markdown 渲染完整内容 if (typeof formatMarkdown === 'function') { assistantDiv.innerHTML = formatMarkdown(streamingTarget); } else { assistantDiv.textContent = streamingTarget; } } // 生成结果后:将执行过程折叠并保留,供后续查看;统一放在「助手回复下方」(与刷新后加载历史一致,最佳实践) if (timelineContainer && timelineContainer.classList.contains('has-items') && !timelineContainer.closest('.webshell-ai-process-block')) { var headerLabel = (typeof window.t === 'function') ? (window.t('chat.penetrationTestDetail') || '执行过程及调用工具') : '执行过程及调用工具'; var wrap = document.createElement('div'); wrap.className = 'process-details-container webshell-ai-process-block'; wrap.innerHTML = '
'; var contentDiv = wrap.querySelector('.process-details-content'); contentDiv.appendChild(timelineContainer); timelineContainer.classList.add('progress-timeline'); messagesContainer.insertBefore(wrap, assistantDiv.nextSibling); var toggleBtn = wrap.querySelector('.webshell-ai-process-toggle'); var toggleIcon = wrap.querySelector('.ws-toggle-icon'); toggleBtn.addEventListener('click', function () { var isExpanded = timelineContainer.classList.contains('expanded'); timelineContainer.classList.toggle('expanded'); toggleBtn.setAttribute('aria-expanded', !isExpanded); if (toggleIcon) toggleIcon.textContent = isExpanded ? '▶' : '▼'; }); } messagesContainer.scrollTop = messagesContainer.scrollHeight; }); } // 打字机效果:将 target 逐字/逐段写入 el,保证只生效于当前 id 的调用 function runWebshellAiStreamingTyping(el, target, id, scrollContainer) { if (!el || id === undefined) return; var chunkSize = 3; var delayMs = 24; function tick() { if (id !== webshellStreamingTypingId) return; var cur = el.textContent || ''; if (cur.length >= target.length) { el.textContent = target; if (scrollContainer) scrollContainer.scrollTop = scrollContainer.scrollHeight; return; } var next = target.slice(0, cur.length + chunkSize); el.textContent = next; if (scrollContainer) scrollContainer.scrollTop = scrollContainer.scrollHeight; setTimeout(tick, delayMs); } if (el.textContent.length < target.length) setTimeout(tick, delayMs); } function getWebshellHistory(connId) { if (!connId) return []; if (!webshellHistoryByConn[connId]) webshellHistoryByConn[connId] = []; return webshellHistoryByConn[connId]; } function pushWebshellHistory(connId, cmd) { if (!connId || !cmd) return; if (!webshellHistoryByConn[connId]) webshellHistoryByConn[connId] = []; var h = webshellHistoryByConn[connId]; if (h[h.length - 1] === cmd) return; h.push(cmd); if (h.length > WEBSHELL_HISTORY_MAX) h.shift(); } // 执行快捷命令并将输出写入当前终端 function runQuickCommand(cmd) { if (!webshellCurrentConn || !webshellTerminalInstance) return; if (webshellRunning || webshellTerminalRunning) return; var term = webshellTerminalInstance; var connId = webshellCurrentConn.id; var sessionId = getActiveWebshellTerminalSessionId(connId); var terminalKey = getWebshellTerminalSessionKey(connId, sessionId); term.writeln(''); pushWebshellHistory(terminalKey, cmd); appendWebshellTerminalLog(terminalKey, '\n$ ' + cmd + '\n'); webshellRunning = true; setWebshellTerminalStatus(true); execWebshellCommand(webshellCurrentConn, cmd).then(function (out) { var s = String(out || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); s.split('\n').forEach(function (line) { term.writeln(line.replace(/\r/g, '')); }); appendWebshellTerminalLog(terminalKey, s + '\n'); term.write(WEBSHELL_PROMPT); }).catch(function (err) { var em = (err && err.message ? err.message : wsT('webshell.execError')); term.writeln('\x1b[31m' + em + '\x1b[0m'); appendWebshellTerminalLog(terminalKey, em + '\n'); term.write(WEBSHELL_PROMPT); }).finally(function () { webshellRunning = false; setWebshellTerminalStatus(false); renderWebshellTerminalSessions(webshellCurrentConn); }); } // ---------- 虚拟终端(xterm + 按行执行) ---------- function initWebshellTerminal(conn) { const container = document.getElementById('webshell-terminal-container'); if (!container || typeof Terminal === 'undefined') { if (container) { container.innerHTML = '

' + escapeHtml('未加载 xterm.js,请刷新页面') + '

'; } return; } const term = new Terminal({ cursorBlink: true, cursorStyle: 'underline', fontSize: 13, fontFamily: 'Menlo, Monaco, "Courier New", monospace', lineHeight: 1.2, scrollback: 2000, theme: { background: '#0d1117', foreground: '#e6edf3', cursor: '#58a6ff', cursorAccent: '#0d1117', selection: 'rgba(88, 166, 255, 0.3)' } }); let fitAddon = null; if (typeof FitAddon !== 'undefined') { const FitCtor = FitAddon.FitAddon || FitAddon; fitAddon = new FitCtor(); term.loadAddon(fitAddon); } term.open(container); // 先 fit 再写内容,避免未计算尺寸时光标/画布错位挡住文字 try { if (fitAddon) fitAddon.fit(); } catch (e) {} setWebshellTerminalStatus(false); var connId = conn && conn.id ? conn.id : ''; var sessionId = getActiveWebshellTerminalSessionId(connId); var terminalKey = getWebshellTerminalSessionKey(connId, sessionId); var cachedLog = getWebshellTerminalLog(terminalKey); if (cachedLog) { // xterm 恢复内容时统一使用 CRLF,避免切换窗口后出现“斜排”错位 term.write(String(cachedLog).replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n/g, '\r\n')); } term.write(WEBSHELL_PROMPT); // 按行写入输出,与系统设置终端 writeOutput 一致,避免 ls 等输出错位 function writeWebshellOutput(term, text, isError) { if (!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' : ''; term.write(prefix); for (var i = 0; i < lines.length; i++) { term.writeln(lines[i].replace(/\r/g, '')); } term.write(suffix); } term.onData(function (data) { // Ctrl+L 清屏 if (data === '\x0c') { term.clear(); webshellLineBuffer = ''; webshellHistoryIndex = -1; term.write(WEBSHELL_PROMPT); clearWebshellTerminalLog(terminalKey); return; } // Ctrl+C:当前实现不支持远程中断,给出提示并回到提示符 if (data === '\x03') { if (webshellTerminalRunning) { writeWebshellOutput(term, '^C (当前版本暂不支持中断远程命令)', true); appendWebshellTerminalLog(terminalKey, '^C (当前版本暂不支持中断远程命令)\n'); } webshellLineBuffer = ''; webshellHistoryIndex = -1; term.write(WEBSHELL_PROMPT); return; } // Ctrl+U:清空当前输入行 if (data === '\x15') { webshellLineBuffer = ''; term.write('\x1b[2K\r' + WEBSHELL_PROMPT); return; } // 上/下键:命令历史 if (data === '\x1b[A' || data === '\x1bOA') { var hist = getWebshellHistory(terminalKey); if (hist.length === 0) return; webshellHistoryIndex = webshellHistoryIndex < 0 ? hist.length : Math.max(0, webshellHistoryIndex - 1); webshellLineBuffer = hist[webshellHistoryIndex] || ''; term.write('\x1b[2K\r' + WEBSHELL_PROMPT + webshellLineBuffer); return; } if (data === '\x1b[B' || data === '\x1bOB') { var hist2 = getWebshellHistory(terminalKey); if (hist2.length === 0) return; webshellHistoryIndex = webshellHistoryIndex < 0 ? -1 : Math.min(hist2.length - 1, webshellHistoryIndex + 1); if (webshellHistoryIndex < 0) webshellLineBuffer = ''; else webshellLineBuffer = hist2[webshellHistoryIndex] || ''; term.write('\x1b[2K\r' + WEBSHELL_PROMPT + webshellLineBuffer); return; } // 回车:发送当前行到后端执行 if (data === '\r' || data === '\n') { term.writeln(''); var cmd = webshellLineBuffer.trim(); webshellLineBuffer = ''; webshellHistoryIndex = -1; if (cmd) { if (webshellRunning) { writeWebshellOutput(term, wsT('webshell.waitFinish'), true); appendWebshellTerminalLog(terminalKey, (wsT('webshell.waitFinish') || '请等待当前命令执行完成') + '\n'); term.write(WEBSHELL_PROMPT); return; } pushWebshellHistory(terminalKey, cmd); appendWebshellTerminalLog(terminalKey, '$ ' + cmd + '\n'); webshellRunning = true; setWebshellTerminalStatus(true); renderWebshellTerminalSessions(conn); execWebshellCommand(webshellCurrentConn, cmd).then(function (out) { webshellRunning = false; setWebshellTerminalStatus(false); renderWebshellTerminalSessions(conn); if (out && out.length) { writeWebshellOutput(term, out, false); appendWebshellTerminalLog(terminalKey, String(out).replace(/\r\n/g, '\n').replace(/\r/g, '\n') + '\n'); } term.write(WEBSHELL_PROMPT); }).catch(function (err) { webshellRunning = false; setWebshellTerminalStatus(false); renderWebshellTerminalSessions(conn); var errMsg = err && err.message ? err.message : wsT('webshell.execError'); writeWebshellOutput(term, errMsg, true); appendWebshellTerminalLog(terminalKey, String(errMsg || '') + '\n'); term.write(WEBSHELL_PROMPT); }); } else { term.write(WEBSHELL_PROMPT); } return; } // 多行粘贴:按行依次执行 if (data.indexOf('\n') !== -1 || data.indexOf('\r') !== -1) { var full = (webshellLineBuffer + data).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); var lines = full.split('\n'); webshellLineBuffer = lines.pop() || ''; if (lines.length > 0 && !webshellRunning && webshellCurrentConn) { var runNext = function (idx) { if (idx >= lines.length) { term.write(WEBSHELL_PROMPT + webshellLineBuffer); return; } var line = lines[idx].trim(); if (!line) { runNext(idx + 1); return; } pushWebshellHistory(terminalKey, line); appendWebshellTerminalLog(terminalKey, '$ ' + line + '\n'); webshellRunning = true; setWebshellTerminalStatus(true); renderWebshellTerminalSessions(conn); execWebshellCommand(webshellCurrentConn, line).then(function (out) { if (out && out.length) { writeWebshellOutput(term, out, false); appendWebshellTerminalLog(terminalKey, String(out).replace(/\r\n/g, '\n').replace(/\r/g, '\n') + '\n'); } webshellRunning = false; setWebshellTerminalStatus(false); renderWebshellTerminalSessions(conn); runNext(idx + 1); }).catch(function (err) { var em = err && err.message ? err.message : wsT('webshell.execError'); writeWebshellOutput(term, em, true); appendWebshellTerminalLog(terminalKey, String(em || '') + '\n'); webshellRunning = false; setWebshellTerminalStatus(false); renderWebshellTerminalSessions(conn); runNext(idx + 1); }); }; runNext(0); } else { term.write(data); } return; } // 退格 if (data === '\x7f' || data === '\b') { if (webshellLineBuffer.length > 0) { webshellLineBuffer = webshellLineBuffer.slice(0, -1); term.write('\b \b'); } return; } webshellLineBuffer += data; term.write(data); }); webshellTerminalInstance = term; webshellTerminalFitAddon = fitAddon; // 延迟再次 fit,确保容器尺寸稳定后光标与文字不错位 setTimeout(function () { try { if (fitAddon) fitAddon.fit(); } catch (e) {} }, 100); // 容器尺寸变化时重新 fit,避免光标/文字被遮挡 if (fitAddon && typeof ResizeObserver !== 'undefined' && container) { webshellTerminalResizeContainer = container; webshellTerminalResizeObserver = new ResizeObserver(function () { try { fitAddon.fit(); } catch (e) {} }); webshellTerminalResizeObserver.observe(container); } renderWebshellTerminalSessions(conn); } // 调用后端执行命令 function execWebshellCommand(conn, command) { return new Promise(function (resolve, reject) { if (typeof apiFetch === 'undefined') { reject(new Error('apiFetch 未定义')); return; } apiFetch('/api/webshell/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', command: command }) }).then(function (r) { return r.json(); }) .then(function (data) { if (data && data.output !== undefined) resolve(data.output || ''); else if (data && data.error) reject(new Error(data.error)); else resolve(''); }) .catch(reject); }); } // ---------- 文件管理 ---------- function webshellFileListDir(conn, path) { const listEl = document.getElementById('webshell-file-list'); if (!listEl) return; listEl.innerHTML = '
' + wsT('common.refresh') + '...
'; if (typeof apiFetch === 'undefined') { listEl.innerHTML = '
apiFetch 未定义
'; return; } apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'list', path: path }) }).then(function (r) { return r.json(); }) .then(function (data) { if (!data.ok && data.error) { listEl.innerHTML = '
' + escapeHtml(data.error) + '
' + escapeHtml(data.output || '') + '
'; return; } listEl.dataset.currentPath = path; listEl.dataset.rawOutput = data.output || ''; renderFileList(listEl, path, data.output || '', conn); }) .catch(function (err) { listEl.innerHTML = '
' + escapeHtml(err && err.message ? err.message : wsT('webshell.execError')) + '
'; }); } function normalizeLsMtime(month, day, timeOrYear) { if (!month || !day || !timeOrYear) return ''; var token = String(timeOrYear).trim(); if (/^\d{4}$/.test(token)) return token + ' ' + month + ' ' + day; var now = new Date(); var year = now.getFullYear(); if (/^\d{1,2}:\d{2}$/.test(token)) { var monthMap = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 }; var m = monthMap[month]; var d = parseInt(day, 10); if (m != null && !isNaN(d)) { var inferred = new Date(year, m, d); if (inferred.getTime() > now.getTime()) year = year - 1; } return year + ' ' + month + ' ' + day + ' ' + token; } return month + ' ' + day + ' ' + token; } function modeToType(mode) { if (!mode || !mode.length) return ''; var c = mode.charAt(0); if (c === 'd') return 'dir'; if (c === '-') return 'file'; if (c === 'l') return 'link'; if (c === 'c') return 'char'; if (c === 'b') return 'block'; if (c === 's') return 'socket'; if (c === 'p') return 'pipe'; return c; } function parseWebshellListItems(rawOutput) { var lines = (rawOutput || '').split(/\n/).filter(function (l) { return l.trim(); }); var items = []; for (var i = 0; i < lines.length; i++) { var line = lines[i]; var name = ''; var isDir = false; var size = ''; var mode = ''; var mtime = ''; var owner = ''; var group = ''; var type = ''; var mLs = line.match(/^(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s+([A-Za-z]{3})\s+(\d{1,2})\s+(\S+)\s+(.+)$/); if (mLs) { mode = mLs[1]; owner = mLs[3]; group = mLs[4]; size = mLs[5]; mtime = normalizeLsMtime(mLs[6], mLs[7], mLs[8]); name = (mLs[9] || '').trim(); isDir = mode && mode.startsWith('d'); type = modeToType(mode); } else { var mName = line.match(/\s*(\S+)\s*$/); name = mName ? mName[1].trim() : line.trim(); if (name === '.' || name === '..') continue; isDir = line.startsWith('d') || line.toLowerCase().indexOf('') !== -1; if (line.startsWith('-') || line.startsWith('d')) { var parts = line.split(/\s+/); if (parts.length >= 5) { mode = parts[0]; size = parts[4]; } if (parts.length >= 4) { owner = parts[2] || ''; group = parts[3] || ''; } if (parts.length >= 8 && /^[A-Za-z]{3}$/.test(parts[5])) mtime = normalizeLsMtime(parts[5], parts[6], parts[7]); type = modeToType(mode); } } if (name === '.' || name === '..') continue; items.push({ name: name, isDir: isDir, line: line, size: size, mode: mode, mtime: mtime, owner: owner, group: group, type: type }); } return items; } function fetchWebshellDirectoryItems(conn, path) { if (!conn || typeof apiFetch === 'undefined') return Promise.resolve([]); return apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'list', path: path }) }).then(function (r) { return r.json(); }).then(function (data) { if (!data || data.error || !data.ok) return []; return parseWebshellListItems(data.output || ''); }).catch(function () { return []; }); } function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { var items = parseWebshellListItems(rawOutput); if (nameFilter && nameFilter.trim()) { var f = nameFilter.trim().toLowerCase(); items = items.filter(function (item) { return item.name.toLowerCase().indexOf(f) !== -1; }); } // 面包屑 var breadcrumbEl = document.getElementById('webshell-file-breadcrumb'); if (breadcrumbEl) { var parts = (currentPath === '.' || currentPath === '') ? [] : currentPath.replace(/^\//, '').split('/'); breadcrumbEl.innerHTML = '' + (wsT('webshell.breadcrumbHome') || '根') + '' + parts.map(function (p, idx) { var path = parts.slice(0, idx + 1).join('/'); return ' / ' + escapeHtml(p) + ''; }).join(''); } renderDirectoryTree(currentPath, items, conn); var html = ''; if (items.length === 0) { // 目录为空/过滤后为空时,给出明确空状态,避免 tbody 留白导致“整块抽象大白屏” if (rawOutput.trim() && !nameFilter) { html = '
' + escapeHtml(rawOutput) + '
'; } else { html = '' + '' + '
' + wsT('webshell.filePath') + '大小' + (wsT('webshell.colModifiedAt') || '修改时间') + '' + (wsT('webshell.colOwner') || '所有者') + '' + (wsT('webshell.colPerms') || '权限') + '
' + (wsT('common.noData') || '暂无文件') + '
'; } } else { html = ''; if (currentPath !== '.' && currentPath !== '') { html += ''; } items.forEach(function (item) { var pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name; var nameClass = item.isDir ? 'is-dir' : 'is-file'; html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; }); html += '
' + wsT('webshell.filePath') + '大小' + (wsT('webshell.colModifiedAt') || '修改时间') + '' + (wsT('webshell.colOwner') || '所有者') + '' + (wsT('webshell.colPerms') || '权限') + '
..
'; if (!item.isDir) html += ''; html += '' + escapeHtml(item.name) + (item.isDir ? '/' : '') + '' + escapeHtml(item.size) + '' + escapeHtml(item.mtime || '') + '' + escapeHtml(item.owner || '') + '' + escapeHtml(item.mode || '') + ''; if (item.isDir) { html += ''; } else { var actionsLabel = wsT('common.actions') || '操作'; html += '
' + actionsLabel + '' + '
' + '' + '' + '' + '' + '' + '
'; } html += '
'; } listEl.innerHTML = html; listEl.querySelectorAll('.webshell-file-link').forEach(function (a) { a.addEventListener('click', function (e) { e.preventDefault(); const path = a.getAttribute('data-path'); const isDir = a.getAttribute('data-isdir') === '1'; const pathInput = document.getElementById('webshell-file-path'); if (isDir) { if (pathInput) pathInput.value = path; webshellFileListDir(webshellCurrentConn, path); } else { // 打开文件时保留当前“浏览目录”上下文,避免返回时落到单文件视图 webshellFileRead(webshellCurrentConn, path, listEl, currentPath); } }); }); listEl.querySelectorAll('.webshell-file-read').forEach(function (btn) { btn.addEventListener('click', function (e) { e.preventDefault(); webshellFileRead(webshellCurrentConn, btn.getAttribute('data-path'), listEl, currentPath); }); }); listEl.querySelectorAll('.webshell-file-download').forEach(function (btn) { btn.addEventListener('click', function (e) { e.preventDefault(); webshellFileDownload(webshellCurrentConn, btn.getAttribute('data-path')); }); }); listEl.querySelectorAll('.webshell-file-edit').forEach(function (btn) { btn.addEventListener('click', function (e) { e.preventDefault(); webshellFileEdit(webshellCurrentConn, btn.getAttribute('data-path'), listEl); }); }); listEl.querySelectorAll('.webshell-file-del').forEach(function (btn) { btn.addEventListener('click', function (e) { e.preventDefault(); if (!confirm(wsT('webshell.deleteConfirm'))) return; webshellFileDelete(webshellCurrentConn, btn.getAttribute('data-path'), function () { webshellFileListDir(webshellCurrentConn, document.getElementById('webshell-file-path').value.trim() || '.'); }); }); }); listEl.querySelectorAll('.webshell-file-rename').forEach(function (btn) { btn.addEventListener('click', function (e) { e.preventDefault(); webshellFileRename(webshellCurrentConn, btn.getAttribute('data-path'), btn.getAttribute('data-name'), listEl); }); }); var selectAll = document.getElementById('webshell-file-select-all'); if (selectAll) { selectAll.addEventListener('change', function () { listEl.querySelectorAll('.webshell-file-cb').forEach(function (cb) { cb.checked = selectAll.checked; }); }); } if (breadcrumbEl) { breadcrumbEl.querySelectorAll('.webshell-breadcrumb-item').forEach(function (a) { a.addEventListener('click', function (e) { e.preventDefault(); var p = a.getAttribute('data-path'); var pathInput = document.getElementById('webshell-file-path'); if (pathInput) pathInput.value = p; webshellFileListDir(webshellCurrentConn, p); }); }); } } function renderDirectoryTree(currentPath, items, conn) { var treeEl = document.getElementById('webshell-dir-tree'); if (!treeEl) return; var state = getWebshellTreeState(conn || webshellCurrentConn); var curr = normalizeWebshellPath(currentPath); var dirs = (items || []).filter(function (item) { return item && item.isDir; }); if (!state) { treeEl.innerHTML = '
暂无目录
'; return; } var tree = state.tree; var expanded = state.expanded; var loaded = state.loaded; if (!tree['.']) tree['.'] = []; if (expanded['.'] !== false) expanded['.'] = true; // 把当前目录的子项(目录+文件)同步到树缓存 var childNodes = (items || []).map(function (item) { var childPath = curr === '.' ? normalizeWebshellPath(item.name) : normalizeWebshellPath(curr + '/' + item.name); return { path: childPath, name: item.name, isDir: !!item.isDir }; }).filter(function (n) { return !!n.path; }); childNodes.sort(function (a, b) { // 目录优先,再按名称排序 if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; return (a.name || '').localeCompare(b.name || ''); }); tree[curr] = childNodes; loaded[curr] = true; childNodes.forEach(function (node) { if (node.isDir && !tree[node.path]) tree[node.path] = []; }); // 确保当前路径祖先链存在并展开 var parts = curr === '.' ? [] : curr.split('/'); var parentPath = '.'; for (var i = 0; i < parts.length; i++) { var nextPath = parentPath === '.' ? parts[i] : parentPath + '/' + parts[i]; if (!tree[parentPath]) tree[parentPath] = []; var parentChildren = tree[parentPath]; var hasAncestorNode = parentChildren.some(function (n) { return n && n.path === nextPath; }); if (!hasAncestorNode) { parentChildren.push({ path: nextPath, name: parts[i], isDir: true }); parentChildren.sort(function (a, b) { if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1; return (a.name || '').localeCompare(b.name || ''); }); } if (!tree[nextPath]) tree[nextPath] = []; expanded[parentPath] = true; parentPath = nextPath; } expanded[curr] = true; function renderNode(node, depth) { var path = node.path; var isDir = !!node.isDir; var children = isDir ? (tree[path] || []).slice() : []; var hasLoadedChildren = isDir ? (loaded[path] === true) : true; var canExpand = isDir && (path === '.' || !hasLoadedChildren || children.length > 0); var hasChildren = children.length > 0; var isExpanded = isDir ? (expanded[path] !== false) : false; var isActive = path === curr; var name = node.name; var icon = isDir ? (path === '.' ? '🗂' : '📁') : '📄'; var nodeHtml = '
' + '
' + '' + '' + '
'; if (isDir && hasChildren && isExpanded) { nodeHtml += '
'; for (var j = 0; j < children.length; j++) { nodeHtml += renderNode(children[j], depth + 1); } nodeHtml += '
'; } nodeHtml += '
'; return nodeHtml; } treeEl.innerHTML = '
' + renderNode({ path: '.', name: '/', isDir: true }, 0) + '
'; treeEl.querySelectorAll('.webshell-tree-toggle').forEach(function (btn) { btn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); var p = normalizeWebshellPath(btn.getAttribute('data-path') || '.'); if (expanded[p] !== false) { expanded[p] = false; renderDirectoryTree(curr, items, conn || webshellCurrentConn); return; } if (loaded[p] === true) { expanded[p] = true; renderDirectoryTree(curr, items, conn || webshellCurrentConn); return; } fetchWebshellDirectoryItems(conn || webshellCurrentConn, p).then(function (subItems) { var nextChildren = (subItems || []).map(function (it) { return { path: p === '.' ? normalizeWebshellPath(it.name) : normalizeWebshellPath(p + '/' + it.name), name: it.name, isDir: !!it.isDir }; }).filter(function (n) { return !!n.path; }).sort(function (a, b) { if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; return (a.name || '').localeCompare(b.name || ''); }); tree[p] = nextChildren; nextChildren.forEach(function (childNode) { if (childNode.isDir) { if (!tree[childNode.path]) tree[childNode.path] = []; if (loaded[childNode.path] == null) loaded[childNode.path] = false; } }); loaded[p] = true; expanded[p] = true; renderDirectoryTree(curr, items, conn || webshellCurrentConn); }); }); }); treeEl.querySelectorAll('.webshell-dir-item').forEach(function (btn) { btn.addEventListener('click', function () { var p = normalizeWebshellPath(btn.getAttribute('data-path') || '.'); var isDir = btn.getAttribute('data-isdir') === '1'; var pathInput = document.getElementById('webshell-file-path'); if (isDir) { if (pathInput) pathInput.value = p; webshellFileListDir(webshellCurrentConn, p); return; } var listEl = document.getElementById('webshell-file-list'); var browsePath = p.replace(/\/[^/]+$/, '') || '.'; if (listEl) webshellFileRead(webshellCurrentConn, p, listEl, browsePath); }); }); } function webshellFileListApplyFilter() { var listEl = document.getElementById('webshell-file-list'); var path = listEl && listEl.dataset.currentPath ? listEl.dataset.currentPath : (document.getElementById('webshell-file-path') && document.getElementById('webshell-file-path').value.trim()) || '.'; var raw = listEl && listEl.dataset.rawOutput ? listEl.dataset.rawOutput : ''; var filterInput = document.getElementById('webshell-file-filter'); var filter = filterInput ? filterInput.value : ''; if (!listEl || !raw) return; renderFileList(listEl, path, raw, webshellCurrentConn, filter); } function webshellFileMkdir(conn, pathInput) { if (!conn || typeof apiFetch === 'undefined') return; var base = (pathInput && pathInput.value.trim()) || '.'; var name = prompt(wsT('webshell.newDir') || '新建目录', 'newdir'); if (name == null || !name.trim()) return; var path = base === '.' ? name.trim() : base + '/' + name.trim(); apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'mkdir', path: path }) }) .then(function (r) { return r.json(); }) .then(function () { webshellFileListDir(conn, base); }) .catch(function () { webshellFileListDir(conn, base); }); } function webshellFileNewFile(conn, pathInput) { if (!conn || typeof apiFetch === 'undefined') return; var base = (pathInput && pathInput.value.trim()) || '.'; var name = prompt(wsT('webshell.newFile') || '新建文件', 'newfile.txt'); if (name == null || !name.trim()) return; var path = base === '.' ? name.trim() : base + '/' + name.trim(); var content = prompt('初始内容(可选)', ''); if (content === null) return; var listEl = document.getElementById('webshell-file-list'); webshellFileWrite(conn, path, content || '', function () { webshellFileListDir(conn, base); }, listEl); } function webshellFileUpload(conn, pathInput) { if (!conn || typeof apiFetch === 'undefined') return; var base = (pathInput && pathInput.value.trim()) || '.'; var input = document.createElement('input'); input.type = 'file'; input.multiple = false; input.onchange = function () { var file = input.files && input.files[0]; if (!file) return; var reader = new FileReader(); reader.onload = function () { var buf = reader.result; var bin = new Uint8Array(buf); var CHUNK = 32000; var base64Chunks = []; for (var i = 0; i < bin.length; i += CHUNK) { var slice = bin.subarray(i, Math.min(i + CHUNK, bin.length)); var b64 = btoa(String.fromCharCode.apply(null, slice)); base64Chunks.push(b64); } var path = base === '.' ? file.name : base + '/' + file.name; var listEl = document.getElementById('webshell-file-list'); if (listEl) listEl.innerHTML = '
' + (wsT('webshell.upload') || '上传') + '...
'; var idx = 0; function sendNext() { if (idx >= base64Chunks.length) { webshellFileListDir(conn, base); return; } apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'upload_chunk', path: path, content: base64Chunks[idx], chunk_index: idx }) }) .then(function (r) { return r.json(); }) .then(function () { idx++; sendNext(); }) .catch(function () { idx++; sendNext(); }); } sendNext(); }; reader.readAsArrayBuffer(file); }; input.click(); } function webshellFileRename(conn, oldPath, oldName, listEl) { if (!conn || typeof apiFetch === 'undefined') return; var newName = prompt((wsT('webshell.rename') || '重命名') + ': ' + oldName, oldName); if (newName == null || newName.trim() === '') return; var parts = oldPath.split('/'); var dir = parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : ''; var newPath = dir + newName.trim(); apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'rename', path: oldPath, target_path: newPath }) }) .then(function (r) { return r.json(); }) .then(function () { webshellFileListDir(conn, document.getElementById('webshell-file-path').value.trim() || '.'); }) .catch(function () { webshellFileListDir(conn, document.getElementById('webshell-file-path').value.trim() || '.'); }); } function webshellBatchDelete(conn, pathInput) { if (!conn) return; var listEl = document.getElementById('webshell-file-list'); var checked = listEl ? listEl.querySelectorAll('.webshell-file-cb:checked') : []; var paths = []; checked.forEach(function (cb) { paths.push(cb.getAttribute('data-path')); }); if (paths.length === 0) { alert(wsT('webshell.batchDelete') + ':请先勾选文件'); return; } if (!confirm(wsT('webshell.batchDelete') + ':确定删除 ' + paths.length + ' 个文件?')) return; var base = (pathInput && pathInput.value.trim()) || '.'; var i = 0; function delNext() { if (i >= paths.length) { webshellFileListDir(conn, base); return; } webshellFileDelete(conn, paths[i], function () { i++; delNext(); }); } delNext(); } function webshellBatchDownload(conn, pathInput) { if (!conn) return; var listEl = document.getElementById('webshell-file-list'); var checked = listEl ? listEl.querySelectorAll('.webshell-file-cb:checked') : []; var paths = []; checked.forEach(function (cb) { paths.push(cb.getAttribute('data-path')); }); if (paths.length === 0) { alert(wsT('webshell.batchDownload') + ':请先勾选文件'); return; } paths.forEach(function (path) { webshellFileDownload(conn, path); }); } // 下载文件到本地(读取内容后触发浏览器下载) function webshellFileDownload(conn, path) { if (typeof apiFetch === 'undefined') return; apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'read', path: path }) }).then(function (r) { return r.json(); }) .then(function (data) { var content = (data && data.output) != null ? data.output : (data.error || ''); var name = path.replace(/^.*[/\\]/, '') || 'download.txt'; var blob = new Blob([content], { type: 'application/octet-stream' }); var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; a.click(); URL.revokeObjectURL(a.href); }) .catch(function (err) { alert(wsT('webshell.execError') + ': ' + (err && err.message ? err.message : '')); }); } function webshellFileRead(conn, path, listEl, browsePath) { if (typeof apiFetch === 'undefined') return; listEl.innerHTML = '
' + wsT('webshell.readFile') + '...
'; apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'read', path: path }) }).then(function (r) { return r.json(); }) .then(function (data) { const out = (data && data.output) ? data.output : (data.error || ''); var backPath = (browsePath && String(browsePath).trim()) ? String(browsePath).trim() : ((document.getElementById('webshell-file-path') && document.getElementById('webshell-file-path').value.trim()) || '.'); if (backPath === path) { // 兜底:若路径被污染成文件路径,回退到父目录 backPath = path.replace(/\/[^/]+$/, '') || '.'; } listEl.innerHTML = '
' + escapeHtml(out) + '
'; var backBtn = document.getElementById('webshell-file-back-btn'); if (backBtn) { backBtn.addEventListener('click', function () { var p = backBtn.getAttribute('data-back-path') || '.'; webshellFileListDir(webshellCurrentConn, p); }); } }) .catch(function (err) { listEl.innerHTML = '
' + escapeHtml(err && err.message ? err.message : '') + '
'; }); } function webshellFileEdit(conn, path, listEl) { if (typeof apiFetch === 'undefined') return; listEl.innerHTML = '
' + wsT('webshell.editFile') + '...
'; apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'read', path: path }) }).then(function (r) { return r.json(); }) .then(function (data) { const content = (data && data.output) ? data.output : (data.error || ''); const pathInput = document.getElementById('webshell-file-path'); const currentPath = pathInput ? pathInput.value.trim() || '.' : '.'; listEl.innerHTML = '
' + '
' + escapeHtml(path) + '
' + '' + '
' + ' ' + '' + '
'; document.getElementById('webshell-edit-save').addEventListener('click', function () { const textarea = document.getElementById('webshell-edit-textarea'); const newContent = textarea ? textarea.value : ''; webshellFileWrite(webshellCurrentConn, path, newContent, function () { webshellFileListDir(webshellCurrentConn, currentPath); }, listEl); }); document.getElementById('webshell-edit-cancel').addEventListener('click', function () { webshellFileListDir(webshellCurrentConn, currentPath); }); }) .catch(function (err) { listEl.innerHTML = '
' + escapeHtml(err && err.message ? err.message : '') + '
'; }); } function webshellFileWrite(conn, path, content, onDone, listEl) { if (typeof apiFetch === 'undefined') return; if (listEl) listEl.innerHTML = '
' + wsT('webshell.saveFile') + '...
'; apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'write', path: path, content: content }) }).then(function (r) { return r.json(); }) .then(function (data) { if (data && !data.ok && data.error && listEl) { listEl.innerHTML = '
' + escapeHtml(data.error) + '
' + escapeHtml(data.output || '') + '
'; return; } if (onDone) onDone(); }) .catch(function (err) { if (listEl) listEl.innerHTML = '
' + escapeHtml(err && err.message ? err.message : wsT('webshell.execError')) + '
'; }); } function webshellFileDelete(conn, path, onDone) { if (typeof apiFetch === 'undefined') return; apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'delete', path: path }) }).then(function (r) { return r.json(); }) .then(function () { if (onDone) onDone(); }) .catch(function () { if (onDone) onDone(); }); } // 删除连接(请求服务端删除后刷新列表) function deleteWebshell(id) { if (!confirm(wsT('webshell.deleteConfirm'))) return; if (currentWebshellId === id) destroyWebshellTerminal(); if (currentWebshellId === id) currentWebshellId = null; // 清理本地缓存(服务端会级联删除 SQLite 里的状态) delete webshellPersistLoadedByConn[id]; if (webshellPersistSaveTimersByConn[id]) { clearTimeout(webshellPersistSaveTimersByConn[id]); delete webshellPersistSaveTimersByConn[id]; } delete webshellTerminalSessionsByConn[id]; var dbStateKey = getWebshellDbStateStorageKey({ id: id }); if (dbStateKey) delete webshellDbConfigByConn[dbStateKey]; Object.keys(webshellTerminalLogsByConn).forEach(function (k) { if (k === id || k.indexOf(id + '::') === 0) delete webshellTerminalLogsByConn[k]; }); Object.keys(webshellHistoryByConn).forEach(function (k) { if (k === id || k.indexOf(id + '::') === 0) delete webshellHistoryByConn[k]; }); if (typeof apiFetch === 'undefined') return; apiFetch('/api/webshell/connections/' + encodeURIComponent(id), { method: 'DELETE' }) .then(function () { return refreshWebshellConnectionsFromServer(); }) .then(function () { const workspace = document.getElementById('webshell-workspace'); if (workspace) { workspace.innerHTML = '
' + wsT('webshell.selectOrAdd') + '
'; } }) .catch(function (e) { console.warn('删除 WebShell 连接失败', e); refreshWebshellConnectionsFromServer(); }); } // 打开添加连接弹窗 function showAddWebshellModal() { var editIdEl = document.getElementById('webshell-edit-id'); if (editIdEl) editIdEl.value = ''; document.getElementById('webshell-url').value = ''; document.getElementById('webshell-password').value = ''; document.getElementById('webshell-type').value = 'php'; document.getElementById('webshell-method').value = 'post'; document.getElementById('webshell-cmd-param').value = ''; document.getElementById('webshell-remark').value = ''; var titleEl = document.getElementById('webshell-modal-title'); if (titleEl) titleEl.textContent = wsT('webshell.addConnection'); var modal = document.getElementById('webshell-modal'); if (modal) modal.style.display = 'block'; } // 打开编辑连接弹窗(预填当前连接信息) function showEditWebshellModal(connId) { var conn = webshellConnections.find(function (c) { return c.id === connId; }); if (!conn) return; var editIdEl = document.getElementById('webshell-edit-id'); if (editIdEl) editIdEl.value = conn.id; document.getElementById('webshell-url').value = conn.url || ''; document.getElementById('webshell-password').value = conn.password || ''; document.getElementById('webshell-type').value = conn.type || 'php'; document.getElementById('webshell-method').value = (conn.method || 'post').toLowerCase(); document.getElementById('webshell-cmd-param').value = conn.cmdParam || ''; document.getElementById('webshell-remark').value = conn.remark || ''; var titleEl = document.getElementById('webshell-modal-title'); if (titleEl) titleEl.textContent = wsT('webshell.editConnectionTitle'); var modal = document.getElementById('webshell-modal'); if (modal) modal.style.display = 'block'; } // 关闭弹窗 function closeWebshellModal() { var editIdEl = document.getElementById('webshell-edit-id'); if (editIdEl) editIdEl.value = ''; var modal = document.getElementById('webshell-modal'); if (modal) modal.style.display = 'none'; } // 语言切换时刷新 WebShell 页面内所有由 JS 生成的文案(不重建终端) function refreshWebshellUIOnLanguageChange() { var page = typeof window.currentPage === 'function' ? window.currentPage() : (window.currentPage || ''); if (page !== 'webshell') return; renderWebshellList(); var workspace = document.getElementById('webshell-workspace'); if (workspace) { if (!currentWebshellId || !webshellCurrentConn) { workspace.innerHTML = '
' + wsT('webshell.selectOrAdd') + '
'; } else { // 只更新标签文案,不重建终端 var tabTerminal = workspace.querySelector('.webshell-tab[data-tab="terminal"]'); var tabFile = workspace.querySelector('.webshell-tab[data-tab="file"]'); var tabAi = workspace.querySelector('.webshell-tab[data-tab="ai"]'); var tabDb = workspace.querySelector('.webshell-tab[data-tab="db"]'); var tabMemo = workspace.querySelector('.webshell-tab[data-tab="memo"]'); if (tabTerminal) tabTerminal.textContent = wsT('webshell.tabTerminal'); if (tabFile) tabFile.textContent = wsT('webshell.tabFileManager'); if (tabAi) tabAi.textContent = wsT('webshell.tabAiAssistant') || 'AI 助手'; if (tabDb) tabDb.textContent = wsT('webshell.tabDbManager') || '数据库管理'; if (tabMemo) tabMemo.textContent = wsT('webshell.tabMemo') || '备忘录'; var quickLabel = workspace.querySelector('.webshell-quick-label'); if (quickLabel) quickLabel.textContent = (wsT('webshell.quickCommands') || '快捷命令') + ':'; var terminalClearBtn = document.getElementById('webshell-terminal-clear'); if (terminalClearBtn) { terminalClearBtn.title = wsT('webshell.clearScreen') || '清屏'; terminalClearBtn.textContent = wsT('webshell.clearScreen') || '清屏'; } var terminalCopyBtn = document.getElementById('webshell-terminal-copy-log'); if (terminalCopyBtn) { terminalCopyBtn.title = wsT('webshell.copyTerminalLog') || '复制日志'; terminalCopyBtn.textContent = wsT('webshell.copyTerminalLog') || '复制日志'; } setWebshellTerminalStatus(webshellTerminalRunning); if (webshellCurrentConn) renderWebshellTerminalSessions(webshellCurrentConn); var pathLabel = workspace.querySelector('.webshell-file-toolbar label span'); var fileSidebarTitle = workspace.querySelector('.webshell-file-sidebar-title'); var fileMoreActionsBtn = workspace.querySelector('.webshell-toolbar-actions-btn'); var listDirBtn = document.getElementById('webshell-list-dir'); var parentDirBtn = document.getElementById('webshell-parent-dir'); if (pathLabel) pathLabel.textContent = wsT('webshell.filePath'); if (fileSidebarTitle) fileSidebarTitle.textContent = wsT('webshell.dirTree') || '目录列表'; if (fileMoreActionsBtn) fileMoreActionsBtn.textContent = wsT('webshell.moreActions') || '更多操作'; if (listDirBtn) listDirBtn.textContent = wsT('webshell.listDir'); if (parentDirBtn) parentDirBtn.textContent = wsT('webshell.parentDir'); // 文件管理工具栏按钮(红框区域):切换语言时立即更新 var refreshBtn = document.getElementById('webshell-file-refresh'); var mkdirBtn = document.getElementById('webshell-mkdir-btn'); var newFileBtn = document.getElementById('webshell-newfile-btn'); var uploadBtn = document.getElementById('webshell-upload-btn'); var batchDeleteBtn = document.getElementById('webshell-batch-delete-btn'); var batchDownloadBtn = document.getElementById('webshell-batch-download-btn'); var filterInput = document.getElementById('webshell-file-filter'); if (refreshBtn) { refreshBtn.title = wsT('webshell.refresh') || '刷新'; refreshBtn.textContent = wsT('webshell.refresh') || '刷新'; } if (mkdirBtn) mkdirBtn.textContent = wsT('webshell.newDir') || '新建目录'; if (newFileBtn) newFileBtn.textContent = wsT('webshell.newFile') || '新建文件'; if (uploadBtn) uploadBtn.textContent = wsT('webshell.upload') || '上传'; if (batchDeleteBtn) batchDeleteBtn.textContent = wsT('webshell.batchDelete') || '批量删除'; if (batchDownloadBtn) batchDownloadBtn.textContent = wsT('webshell.batchDownload') || '批量下载'; if (filterInput) filterInput.placeholder = wsT('webshell.filterPlaceholder') || '过滤文件名'; // AI 助手区域文案:Tab 内按钮、占位符、系统就绪提示 var aiNewConvBtn = document.getElementById('webshell-ai-new-conv'); if (aiNewConvBtn) aiNewConvBtn.textContent = wsT('webshell.aiNewConversation') || '新对话'; var aiInput = document.getElementById('webshell-ai-input'); if (aiInput) aiInput.placeholder = wsT('webshell.aiPlaceholder') || '例如:列出当前目录下的文件'; var aiSendBtn = document.getElementById('webshell-ai-send'); if (aiSendBtn) aiSendBtn.textContent = wsT('webshell.aiSend') || '发送'; var aiMemoTitle = document.querySelector('.webshell-memo-head span'); if (aiMemoTitle) aiMemoTitle.textContent = wsT('webshell.aiMemo') || '备忘录'; var aiMemoClearBtn = document.getElementById('webshell-ai-memo-clear'); if (aiMemoClearBtn) aiMemoClearBtn.textContent = wsT('webshell.aiMemoClear') || '清空'; var aiMemoInput = document.getElementById('webshell-ai-memo-input'); if (aiMemoInput) aiMemoInput.placeholder = wsT('webshell.aiMemoPlaceholder') || '记录关键命令、测试思路、复现步骤...'; var aiMemoStatus = document.getElementById('webshell-ai-memo-status'); if (aiMemoStatus && !aiMemoStatus.classList.contains('error')) { var savingText = wsT('webshell.aiMemoSaving') || '保存中...'; var savedText = wsT('webshell.aiMemoSaved') || '已保存到本地'; aiMemoStatus.textContent = aiMemoStatus.textContent === savingText ? savingText : savedText; } var dbTypeLabel = document.querySelector('#webshell-db-type') ? document.querySelector('#webshell-db-type').closest('label') : null; if (dbTypeLabel && dbTypeLabel.querySelector('span')) dbTypeLabel.querySelector('span').textContent = wsT('webshell.dbType') || '数据库类型'; var dbProfileNameLabel = document.querySelector('#webshell-db-profile-name') ? document.querySelector('#webshell-db-profile-name').closest('label') : null; if (dbProfileNameLabel && dbProfileNameLabel.querySelector('span')) dbProfileNameLabel.querySelector('span').textContent = wsT('webshell.dbProfileName') || '连接名称'; var dbHostLabel = document.querySelector('#webshell-db-host') ? document.querySelector('#webshell-db-host').closest('label') : null; if (dbHostLabel && dbHostLabel.querySelector('span')) dbHostLabel.querySelector('span').textContent = wsT('webshell.dbHost') || '主机'; var dbPortLabel = document.querySelector('#webshell-db-port') ? document.querySelector('#webshell-db-port').closest('label') : null; if (dbPortLabel && dbPortLabel.querySelector('span')) dbPortLabel.querySelector('span').textContent = wsT('webshell.dbPort') || '端口'; var dbUserLabel = document.querySelector('#webshell-db-user') ? document.querySelector('#webshell-db-user').closest('label') : null; if (dbUserLabel && dbUserLabel.querySelector('span')) dbUserLabel.querySelector('span').textContent = wsT('webshell.dbUsername') || '用户名'; var dbPassLabel = document.querySelector('#webshell-db-pass') ? document.querySelector('#webshell-db-pass').closest('label') : null; if (dbPassLabel && dbPassLabel.querySelector('span')) dbPassLabel.querySelector('span').textContent = wsT('webshell.dbPassword') || '密码'; var dbNameLabel = document.querySelector('#webshell-db-name') ? document.querySelector('#webshell-db-name').closest('label') : null; if (dbNameLabel && dbNameLabel.querySelector('span')) dbNameLabel.querySelector('span').textContent = wsT('webshell.dbName') || '数据库名'; var dbSqliteLabel = document.querySelector('#webshell-db-sqlite-path') ? document.querySelector('#webshell-db-sqlite-path').closest('label') : null; if (dbSqliteLabel && dbSqliteLabel.querySelector('span')) dbSqliteLabel.querySelector('span').textContent = wsT('webshell.dbSqlitePath') || 'SQLite 文件路径'; var dbSchemaTitle = document.querySelector('.webshell-db-sidebar-head span'); if (dbSchemaTitle) dbSchemaTitle.textContent = wsT('webshell.dbSchema') || '数据库结构'; var dbLoadSchemaBtn = document.getElementById('webshell-db-load-schema-btn'); if (dbLoadSchemaBtn) dbLoadSchemaBtn.textContent = wsT('webshell.dbLoadSchema') || '加载结构'; var dbTemplateBtn = document.getElementById('webshell-db-template-btn'); if (dbTemplateBtn) dbTemplateBtn.textContent = wsT('webshell.dbTemplateSql') || '示例 SQL'; var dbClearBtn = document.getElementById('webshell-db-clear-btn'); if (dbClearBtn) dbClearBtn.textContent = wsT('webshell.dbClearSql') || '清空 SQL'; var dbRunBtn = document.getElementById('webshell-db-run-btn'); if (dbRunBtn) dbRunBtn.textContent = wsT('webshell.dbRunSql') || '执行 SQL'; var dbTestBtn = document.getElementById('webshell-db-test-btn'); if (dbTestBtn) dbTestBtn.textContent = wsT('webshell.dbTest') || '测试连接'; var dbSql = document.getElementById('webshell-db-sql'); if (dbSql) dbSql.placeholder = wsT('webshell.dbSqlPlaceholder') || '输入 SQL,例如:SELECT version();'; var dbTitle = document.querySelector('.webshell-db-output-title'); if (dbTitle) dbTitle.textContent = wsT('webshell.dbOutput') || '执行输出'; var dbHint = document.querySelector('.webshell-db-hint'); if (dbHint) dbHint.textContent = wsT('webshell.dbCliHint') || '如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd)'; var dbTreeHint = document.querySelector('.webshell-db-sidebar-hint'); if (dbTreeHint) dbTreeHint.textContent = wsT('webshell.dbSelectTableHint') || '点击表名可生成查询 SQL'; var dbAddProfileBtn = document.getElementById('webshell-db-add-profile-btn'); if (dbAddProfileBtn) dbAddProfileBtn.textContent = '+ ' + (wsT('webshell.dbAddProfile') || '新增连接'); var dbProfileModalTitle = document.getElementById('webshell-db-profile-modal-title'); if (dbProfileModalTitle) dbProfileModalTitle.textContent = wsT('webshell.editConnectionTitle') || '编辑连接'; var dbProfileCancelBtn = document.getElementById('webshell-db-profile-cancel-btn'); if (dbProfileCancelBtn) dbProfileCancelBtn.textContent = '取消'; var dbProfileSaveBtn = document.getElementById('webshell-db-profile-save-btn'); if (dbProfileSaveBtn) dbProfileSaveBtn.textContent = '保存'; document.querySelectorAll('.webshell-db-profile-menu[data-action="edit"]').forEach(function (el) { el.title = wsT('webshell.editConnection') || '编辑'; }); document.querySelectorAll('.webshell-db-profile-menu[data-action="delete"]').forEach(function (el) { el.title = wsT('webshell.dbDeleteProfile') || '删除连接'; }); var dbTree = document.getElementById('webshell-db-schema-tree'); if (dbTree && !dbTree.querySelector('.webshell-db-group')) { dbTree.innerHTML = '
' + escapeHtml(wsT('webshell.dbNoSchema') || '暂无数据库结构,请先加载') + '
'; } // 如果当前 AI 对话区只有系统就绪提示(没有用户消息),用当前语言重置这条提示 var aiMessages = document.getElementById('webshell-ai-messages'); if (aiMessages) { var hasUserMsg = !!aiMessages.querySelector('.webshell-ai-msg.user'); var msgNodes = aiMessages.querySelectorAll('.webshell-ai-msg'); if (!hasUserMsg && msgNodes.length <= 1) { var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; aiMessages.innerHTML = ''; var readyDiv = document.createElement('div'); readyDiv.className = 'webshell-ai-msg assistant'; readyDiv.textContent = readyMsg; aiMessages.appendChild(readyDiv); } } var pathInput = document.getElementById('webshell-file-path'); var fileListEl = document.getElementById('webshell-file-list'); if (fileListEl && webshellCurrentConn && pathInput) { webshellFileListDir(webshellCurrentConn, pathInput.value.trim() || '.'); } // 连接搜索占位符(动态属性:这里手动更新) var connSearchEl = document.getElementById('webshell-conn-search'); if (connSearchEl) { var ph = wsT('webshell.searchPlaceholder') || '搜索连接...'; connSearchEl.setAttribute('placeholder', ph); connSearchEl.placeholder = ph; } } } var modal = document.getElementById('webshell-modal'); if (modal && modal.style.display === 'block') { var titleEl = document.getElementById('webshell-modal-title'); var editIdEl = document.getElementById('webshell-edit-id'); if (titleEl) { titleEl.textContent = (editIdEl && editIdEl.value) ? wsT('webshell.editConnectionTitle') : wsT('webshell.addConnection'); } if (typeof window.applyTranslations === 'function') { window.applyTranslations(modal); } } } document.addEventListener('languagechange', function () { refreshWebshellUIOnLanguageChange(); }); // 任意入口删除对话后同步:若当前在 WebShell AI 助手且已选连接,则刷新对话列表(与 Chat 侧边栏删除保持一致) document.addEventListener('conversation-deleted', function (e) { var id = e.detail && e.detail.conversationId; if (!id || !currentWebshellId || !webshellCurrentConn) return; var listEl = document.getElementById('webshell-ai-conv-list'); if (listEl) fetchAndRenderWebshellAiConvList(webshellCurrentConn, listEl); if (webshellAiConvMap[webshellCurrentConn.id] === id) { delete webshellAiConvMap[webshellCurrentConn.id]; var msgs = document.getElementById('webshell-ai-messages'); if (msgs) msgs.innerHTML = ''; } }); // 测试连通性(不保存,仅用当前表单参数请求 Shell 执行 echo 1) function testWebshellConnection() { var url = (document.getElementById('webshell-url') || {}).value; if (url && typeof url.trim === 'function') url = url.trim(); if (!url) { alert(wsT('webshell.url') ? (wsT('webshell.url') + ' 必填') : '请填写 Shell 地址'); return; } var password = (document.getElementById('webshell-password') || {}).value; if (password && typeof password.trim === 'function') password = password.trim(); else password = ''; var type = (document.getElementById('webshell-type') || {}).value || 'php'; var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase(); var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value; if (cmdParam && typeof cmdParam.trim === 'function') cmdParam = cmdParam.trim(); else cmdParam = ''; var btn = document.getElementById('webshell-test-btn'); if (btn) { btn.disabled = true; btn.textContent = (typeof wsT === 'function' ? wsT('common.refresh') : '刷新') + '...'; } if (typeof apiFetch === 'undefined') { if (btn) { btn.disabled = false; btn.textContent = wsT('webshell.testConnectivity'); } alert(wsT('webshell.testFailed') || '连通性测试失败'); return; } apiFetch('/api/webshell/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url, password: password || '', type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam || '', command: 'echo 1' }) }) .then(function (r) { return r.json(); }) .then(function (data) { if (btn) { btn.disabled = false; btn.textContent = wsT('webshell.testConnectivity'); } if (!data) { alert(wsT('webshell.testFailed') || '连通性测试失败'); return; } // 仅 HTTP 200 不算通过,需校验是否真的执行了 echo 1(响应体 trim 后应为 "1") var output = (data.output != null) ? String(data.output).trim() : ''; var reallyOk = data.ok && output === '1'; if (reallyOk) { alert(wsT('webshell.testSuccess') || '连通性正常,Shell 可访问'); } else { var msg; if (data.ok && output !== '1') msg = wsT('webshell.testNoExpectedOutput') || 'Shell 返回了响应但未得到预期输出,请检查连接密码与命令参数名'; else msg = (data.error) ? data.error : (wsT('webshell.testFailed') || '连通性测试失败'); if (data.http_code) msg += ' (HTTP ' + data.http_code + ')'; alert(msg); } }) .catch(function (e) { if (btn) { btn.disabled = false; btn.textContent = wsT('webshell.testConnectivity'); } alert((wsT('webshell.testFailed') || '连通性测试失败') + ': ' + (e && e.message ? e.message : String(e))); }); } // 保存连接(新建或更新,请求服务端写入 SQLite 后刷新列表) function saveWebshellConnection() { var url = (document.getElementById('webshell-url') || {}).value; if (url && typeof url.trim === 'function') url = url.trim(); if (!url) { alert('请填写 Shell 地址'); return; } var password = (document.getElementById('webshell-password') || {}).value; if (password && typeof password.trim === 'function') password = password.trim(); else password = ''; var type = (document.getElementById('webshell-type') || {}).value || 'php'; var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase(); var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value; if (cmdParam && typeof cmdParam.trim === 'function') cmdParam = cmdParam.trim(); else cmdParam = ''; var remark = (document.getElementById('webshell-remark') || {}).value; if (remark && typeof remark.trim === 'function') remark = remark.trim(); else remark = ''; var editIdEl = document.getElementById('webshell-edit-id'); var editId = editIdEl ? editIdEl.value.trim() : ''; var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, remark: remark || url }; if (typeof apiFetch === 'undefined') return; var reqUrl = editId ? ('/api/webshell/connections/' + encodeURIComponent(editId)) : '/api/webshell/connections'; var reqMethod = editId ? 'PUT' : 'POST'; apiFetch(reqUrl, { method: reqMethod, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(function (r) { return r.json(); }) .then(function () { closeWebshellModal(); return refreshWebshellConnectionsFromServer(); }) .then(function (list) { // 若编辑的是当前选中的连接,同步更新 webshellCurrentConn,使终端/文件管理立即使用新配置 if (editId && currentWebshellId === editId && Array.isArray(list)) { var updated = list.find(function (c) { return c.id === editId; }); if (updated) webshellCurrentConn = updated; } }) .catch(function (e) { console.warn('保存 WebShell 连接失败', e); alert(e && e.message ? e.message : '保存失败'); }); }