mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-03-31 16:20:28 +02:00
3980 lines
211 KiB
JavaScript
3980 lines
211 KiB
JavaScript
// 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 = '<div class="webshell-workspace-placeholder" data-i18n="webshell.selectOrAdd">' + (wsT('webshell.selectOrAdd')) + '</div>';
|
||
}
|
||
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 = '<div class="webshell-empty" data-i18n="webshell.noConnections">' + (wsT('webshell.noConnections')) + '</div>';
|
||
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 = '<div class="webshell-empty">' + (wsT('webshell.noMatchConnections') || '暂无匹配连接') + '</div>';
|
||
return;
|
||
}
|
||
|
||
listEl.innerHTML = filtered.map(conn => {
|
||
const remark = (conn.remark || conn.url || '').replace(/</g, '<').replace(/>/g, '>');
|
||
const url = (conn.url || '').replace(/</g, '<').replace(/>/g, '>');
|
||
const urlTitle = (conn.url || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||
const active = currentWebshellId === conn.id ? ' active' : '';
|
||
const safeId = escapeHtml(conn.id);
|
||
const actionsLabel = wsT('common.actions') || '操作';
|
||
const probe = webshellProbeStatusById[conn.id] || null;
|
||
var probeHtml = '';
|
||
if (probe && probe.state === 'probing') {
|
||
probeHtml = '<span class="webshell-probe-badge probing">' + (wsT('webshell.probeRunning') || '探活中') + '</span>';
|
||
} else if (probe && probe.state === 'ok') {
|
||
probeHtml = '<span class="webshell-probe-badge ok">' + (wsT('webshell.probeOnline') || '在线') + '</span>';
|
||
} else if (probe && probe.state === 'fail') {
|
||
probeHtml = '<span class="webshell-probe-badge fail" title="' + escapeHtml(probe.message || '') + '">' + (wsT('webshell.probeOffline') || '离线') + '</span>';
|
||
}
|
||
return (
|
||
'<div class="webshell-item' + active + '" data-id="' + safeId + '">' +
|
||
'<div class="webshell-item-remark-row"><div class="webshell-item-remark" title="' + urlTitle + '">' + remark + '</div>' + probeHtml + '</div>' +
|
||
'<div class="webshell-item-url" title="' + urlTitle + '">' + url + '</div>' +
|
||
'<div class="webshell-item-actions">' +
|
||
'<details class="webshell-conn-actions"><summary class="btn-ghost btn-sm webshell-conn-actions-btn" title="' + actionsLabel + '">' + actionsLabel + '</summary>' +
|
||
'<div class="webshell-row-actions-menu">' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-edit-conn-btn" data-id="' + safeId + '" title="' + wsT('webshell.editConnection') + '">' + wsT('webshell.editConnection') + '</button>' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-delete-btn" data-id="' + safeId + '" title="' + wsT('common.delete') + '">' + wsT('common.delete') + '</button>' +
|
||
'</div></details>' +
|
||
'</div>' +
|
||
'</div>'
|
||
);
|
||
}).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 += '<div class="webshell-terminal-session' + (active ? ' active' : '') + '">' +
|
||
'<button type="button" class="webshell-terminal-session-main" data-action="switch" data-terminal-id="' + escapeHtml(s.id) + '">' + escapeHtml(s.name) + '</button>' +
|
||
'<button type="button" class="webshell-terminal-session-close" data-action="close" data-terminal-id="' + escapeHtml(s.id) + '" title="' + escapeHtml(wsT('common.close') || '关闭') + '">×</button>' +
|
||
'</div>';
|
||
});
|
||
html += '<button type="button" class="webshell-terminal-session-add" data-action="add" title="' + escapeHtml(wsT('webshell.terminalNewWindow') || '新终端') + '">+</button>';
|
||
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 = '<table class="webshell-db-table"><thead><tr>';
|
||
header.forEach(function (h) { html += '<th>' + escapeHtml(h || '-') + '</th>'; });
|
||
html += '</tr></thead><tbody>';
|
||
for (var r = 0; r < maxRows; r++) {
|
||
html += '<tr>';
|
||
rows[r].forEach(function (c) { html += '<td>' + escapeHtml(c || '') + '</td>'; });
|
||
html += '</tr>';
|
||
}
|
||
html += '</tbody></table>';
|
||
if (rows.length > maxRows) {
|
||
html += '<div class="webshell-db-table-meta">仅展示前 ' + maxRows + ' 行,共 ' + rows.length + ' 行</div>';
|
||
} else {
|
||
html += '<div class="webshell-db-table-meta">共 ' + rows.length + ' 行,' + header.length + ' 列</div>';
|
||
}
|
||
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 = '<span class="webshell-ai-timeline-title">' + escapeHtml(title || '') + '</span>';
|
||
if (eventType === 'eino_agent_reply' && detail.message) {
|
||
html += '<div class="webshell-ai-timeline-msg"><pre style="white-space:pre-wrap;">' + escapeHtml(detail.message) + '</pre></div>';
|
||
}
|
||
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 += '<div class="webshell-ai-timeline-msg"><div class="tool-arg-section"><strong>' + escapeHtml(paramsLabel) + '</strong><pre class="tool-args">' + escapeHtml(JSON.stringify(args, null, 2)) + '</pre></div></div>';
|
||
}
|
||
} 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 += '<div class="webshell-ai-timeline-msg"><div class="tool-result-section ' + (isError ? 'error' : 'success') + '"><strong>' + escapeHtml(execResultLabel) + '</strong><pre class="tool-result">' + escapeHtml(resultStr) + '</pre>' + (data.executionId ? '<div class="tool-execution-id"><span>' + escapeHtml(execIdLabel) + '</span> <code>' + escapeHtml(String(data.executionId)) + '</code></div>' : '') + '</div></div>';
|
||
} else if (detail.message && detail.message !== title) {
|
||
html += '<div class="webshell-ai-timeline-msg">' + escapeHtml(detail.message) + '</div>';
|
||
}
|
||
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 = '<button type="button" class="webshell-ai-process-toggle" aria-expanded="' + (!collapsed) + '">' + escapeHtml(headerLabel) + ' <span class="ws-toggle-icon">' + (collapsed ? '▶' : '▼') + '</span></button><div class="process-details-content"><div class="progress-timeline webshell-ai-timeline has-items' + (collapsed ? '' : ' expanded') + '"></div></div>';
|
||
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 = '<span class="webshell-ai-conv-item-title">' + escapeHtml(title) + '</span><span class="webshell-ai-conv-item-date">' + escapeHtml(dateStr) + '</span>';
|
||
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 = '<div class="webshell-workspace-placeholder">' + wsT('webshell.selectOrAdd') + '</div>';
|
||
return;
|
||
}
|
||
if (!stateReady) {
|
||
ensureWebshellPersistStateLoaded(conn).then(function () {
|
||
if (currentWebshellId === id) selectWebshell(id, true);
|
||
});
|
||
return;
|
||
}
|
||
|
||
destroyWebshellTerminal();
|
||
webshellCurrentConn = conn;
|
||
|
||
workspace.innerHTML =
|
||
'<div class="webshell-tabs">' +
|
||
'<button type="button" class="webshell-tab active" data-tab="terminal">' + wsT('webshell.tabTerminal') + '</button>' +
|
||
'<button type="button" class="webshell-tab" data-tab="file">' + wsT('webshell.tabFileManager') + '</button>' +
|
||
'<button type="button" class="webshell-tab" data-tab="db">' + (wsT('webshell.tabDbManager') || '数据库管理') + '</button>' +
|
||
'<button type="button" class="webshell-tab" data-tab="ai">' + (wsT('webshell.tabAiAssistant') || 'AI 助手') + '</button>' +
|
||
'<button type="button" class="webshell-tab" data-tab="memo">' + (wsT('webshell.tabMemo') || '备忘录') + '</button>' +
|
||
'</div>' +
|
||
'<div id="webshell-pane-terminal" class="webshell-pane active">' +
|
||
'<div class="webshell-terminal-toolbar">' +
|
||
'<button type="button" class="btn-ghost btn-sm" id="webshell-terminal-clear" title="' + (wsT('webshell.clearScreen') || '清屏') + '">' + (wsT('webshell.clearScreen') || '清屏') + '</button> ' +
|
||
'<button type="button" class="btn-ghost btn-sm" id="webshell-terminal-copy-log" title="' + (wsT('webshell.copyTerminalLog') || '复制日志') + '">' + (wsT('webshell.copyTerminalLog') || '复制日志') + '</button> ' +
|
||
'<span id="webshell-terminal-status" class="webshell-terminal-status idle">' + (wsT('webshell.terminalIdle') || '空闲') + '</span> ' +
|
||
'<span class="webshell-quick-label">' + (wsT('webshell.quickCommands') || '快捷命令') + ':</span> ' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-quick-cmd" data-cmd="whoami">whoami</button> ' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-quick-cmd" data-cmd="id">id</button> ' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-quick-cmd" data-cmd="pwd">pwd</button> ' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-quick-cmd" data-cmd="ls -la">ls -la</button> ' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-quick-cmd" data-cmd="uname -a">uname -a</button> ' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-quick-cmd" data-cmd="ifconfig">ifconfig</button> ' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-quick-cmd" data-cmd="ip a">ip a</button> ' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-quick-cmd" data-cmd="env">env</button> ' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-quick-cmd" data-cmd="hostname">hostname</button> ' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-quick-cmd" data-cmd="ps aux">ps aux</button> ' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-quick-cmd" data-cmd="netstat -tulnp">netstat</button>' +
|
||
'</div>' +
|
||
'<div class="webshell-terminal-shell">' +
|
||
'<div id="webshell-terminal-sessions" class="webshell-terminal-sessions"></div>' +
|
||
'<div id="webshell-terminal-container" class="webshell-terminal-container"></div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div id="webshell-pane-file" class="webshell-pane">' +
|
||
'<div class="webshell-file-layout">' +
|
||
'<aside class="webshell-file-sidebar">' +
|
||
'<div class="webshell-file-sidebar-title">' + wsTOr('webshell.dirTree', '目录列表') + '</div>' +
|
||
'<div id="webshell-dir-tree" class="webshell-dir-tree"></div>' +
|
||
'</aside>' +
|
||
'<section class="webshell-file-main">' +
|
||
'<div class="webshell-file-toolbar">' +
|
||
'<div class="webshell-file-breadcrumb" id="webshell-file-breadcrumb"></div>' +
|
||
'<div class="webshell-file-toolbar-main">' +
|
||
'<label class="webshell-file-path-field"><span>' + wsT('webshell.filePath') + '</span> <input type="text" id="webshell-file-path" class="form-control" value="." /></label>' +
|
||
'<input type="text" id="webshell-file-filter" class="form-control webshell-file-filter" placeholder="' + (wsT('webshell.filterPlaceholder') || '过滤文件名') + '" />' +
|
||
'<button type="button" class="btn-secondary" id="webshell-list-dir">' + wsT('webshell.listDir') + '</button>' +
|
||
'<button type="button" class="btn-ghost" id="webshell-parent-dir">' + wsT('webshell.parentDir') + '</button>' +
|
||
'</div>' +
|
||
'<div class="webshell-file-toolbar-actions">' +
|
||
'<button type="button" class="btn-ghost" id="webshell-file-refresh" title="' + (wsT('webshell.refresh') || '刷新') + '">' + (wsT('webshell.refresh') || '刷新') + '</button>' +
|
||
'<details class="webshell-toolbar-actions">' +
|
||
'<summary class="btn-ghost webshell-toolbar-actions-btn">' + (wsT('webshell.moreActions') || '更多操作') + '</summary>' +
|
||
'<div class="webshell-row-actions-menu">' +
|
||
'<button type="button" class="btn-ghost" id="webshell-mkdir-btn">' + (wsT('webshell.newDir') || '新建目录') + '</button>' +
|
||
'<button type="button" class="btn-ghost" id="webshell-newfile-btn">' + (wsT('webshell.newFile') || '新建文件') + '</button>' +
|
||
'<button type="button" class="btn-ghost" id="webshell-upload-btn">' + (wsT('webshell.upload') || '上传') + '</button>' +
|
||
'<button type="button" class="btn-ghost" id="webshell-batch-delete-btn">' + (wsT('webshell.batchDelete') || '批量删除') + '</button>' +
|
||
'<button type="button" class="btn-ghost" id="webshell-batch-download-btn">' + (wsT('webshell.batchDownload') || '批量下载') + '</button>' +
|
||
'</div></details>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div id="webshell-file-list" class="webshell-file-list"></div>' +
|
||
'</section>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div id="webshell-pane-ai" class="webshell-pane webshell-pane-ai-with-sidebar">' +
|
||
'<div class="webshell-ai-sidebar">' +
|
||
'<button type="button" class="btn-primary btn-sm webshell-ai-new-btn" id="webshell-ai-new-conv">' + (wsT('webshell.aiNewConversation') || '新对话') + '</button>' +
|
||
'<div class="webshell-ai-conv-list" id="webshell-ai-conv-list"></div>' +
|
||
'</div>' +
|
||
'<div class="webshell-ai-main">' +
|
||
'<div id="webshell-ai-messages" class="webshell-ai-messages"></div>' +
|
||
'<div class="webshell-ai-input-row">' +
|
||
'<textarea id="webshell-ai-input" class="webshell-ai-input form-control" rows="2" placeholder="' + (wsT('webshell.aiPlaceholder') || '例如:列出当前目录下的文件') + '"></textarea>' +
|
||
'<button type="button" class="btn-primary" id="webshell-ai-send">' + (wsT('webshell.aiSend') || '发送') + '</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div id="webshell-pane-memo" class="webshell-pane webshell-pane-memo">' +
|
||
'<div class="webshell-memo-layout">' +
|
||
'<div class="webshell-memo-head"><span>' + (wsT('webshell.aiMemo') || '备忘录') + '</span><button type="button" class="btn-ghost btn-sm" id="webshell-ai-memo-clear">' + (wsT('webshell.aiMemoClear') || '清空') + '</button></div>' +
|
||
'<textarea id="webshell-ai-memo-input" class="webshell-memo-input form-control" rows="18" placeholder="' + (wsT('webshell.aiMemoPlaceholder') || '记录关键命令、测试思路、复现步骤...') + '"></textarea>' +
|
||
'<div id="webshell-ai-memo-status" class="webshell-memo-status">' + (wsT('webshell.aiMemoSaved') || '已保存到本地') + '</div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div id="webshell-pane-db" class="webshell-pane webshell-pane-db">' +
|
||
'<div class="webshell-db-profiles-bar"><div id="webshell-db-profiles" class="webshell-db-profiles"></div><div class="webshell-db-profile-actions"><button type="button" class="btn-ghost btn-sm" id="webshell-db-add-profile-btn">+ ' + (wsT('webshell.dbAddProfile') || '新增连接') + '</button></div></div>' +
|
||
'<div class="webshell-db-layout">' +
|
||
'<aside class="webshell-db-sidebar">' +
|
||
'<div class="webshell-db-sidebar-head"><span>' + (wsT('webshell.dbSchema') || '数据库结构') + '</span><button type="button" class="btn-ghost btn-sm" id="webshell-db-load-schema-btn">' + (wsT('webshell.dbLoadSchema') || '加载结构') + '</button></div>' +
|
||
'<div id="webshell-db-schema-tree" class="webshell-db-schema-tree"><div class="webshell-empty">' + (wsT('webshell.dbNoSchema') || '暂无数据库结构,请先加载') + '</div></div>' +
|
||
'<div class="webshell-db-sidebar-hint">' + (wsT('webshell.dbSelectTableHint') || '点击表名可生成查询 SQL') + '</div>' +
|
||
'</aside>' +
|
||
'<section class="webshell-db-main">' +
|
||
'<div class="webshell-db-sql-tools"><button type="button" class="btn-ghost btn-sm" id="webshell-db-template-btn">' + (wsT('webshell.dbTemplateSql') || '示例 SQL') + '</button><button type="button" class="btn-ghost btn-sm" id="webshell-db-clear-btn">' + (wsT('webshell.dbClearSql') || '清空 SQL') + '</button></div>' +
|
||
'<textarea id="webshell-db-sql" class="webshell-db-sql form-control" rows="8" placeholder="' + (wsT('webshell.dbSqlPlaceholder') || '输入 SQL,例如:SELECT version();') + '"></textarea>' +
|
||
'<div class="webshell-db-actions">' +
|
||
'<button type="button" class="btn-ghost" id="webshell-db-test-btn">' + (wsT('webshell.dbTest') || '测试连接') + '</button>' +
|
||
'<button type="button" class="btn-primary" id="webshell-db-run-btn">' + (wsT('webshell.dbRunSql') || '执行 SQL') + '</button>' +
|
||
'</div>' +
|
||
'<div class="webshell-db-output-wrap"><div class="webshell-db-output-title">' + (wsT('webshell.dbOutput') || '执行输出') + '</div><div id="webshell-db-result-table" class="webshell-db-result-table"></div><pre id="webshell-db-output" class="webshell-db-output"></pre><div class="webshell-db-hint">' + (wsT('webshell.dbCliHint') || '如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd)') + '</div></div>' +
|
||
'<div id="webshell-db-profile-modal" class="modal">' +
|
||
'<div class="modal-content webshell-db-profile-modal-content">' +
|
||
'<div class="modal-header"><h2 id="webshell-db-profile-modal-title">' + (wsT('webshell.editConnectionTitle') || '编辑连接') + '</h2><span class="modal-close" id="webshell-db-profile-modal-close">×</span></div>' +
|
||
'<div class="modal-body">' +
|
||
'<div class="webshell-db-toolbar">' +
|
||
'<label><span>' + (wsT('webshell.dbProfileName') || '连接名称') + '</span><input id="webshell-db-profile-name" class="form-control" type="text" maxlength="30" /></label>' +
|
||
'<label><span>' + (wsT('webshell.dbType') || '数据库类型') + '</span><select id="webshell-db-type" class="form-control"><option value="mysql">MySQL</option><option value="pgsql">PostgreSQL</option><option value="sqlite">SQLite</option><option value="mssql">SQL Server</option></select></label>' +
|
||
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbHost') || '主机') + '</span><input id="webshell-db-host" class="form-control" type="text" value="127.0.0.1" /></label>' +
|
||
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbPort') || '端口') + '</span><input id="webshell-db-port" class="form-control" type="text" /></label>' +
|
||
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbUsername') || '用户名') + '</span><input id="webshell-db-user" class="form-control" type="text" /></label>' +
|
||
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbPassword') || '密码') + '</span><input id="webshell-db-pass" class="form-control" type="password" /></label>' +
|
||
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbName') || '数据库名') + '</span><input id="webshell-db-name" class="form-control" type="text" /></label>' +
|
||
'<label id="webshell-db-sqlite-row"><span>' + (wsT('webshell.dbSqlitePath') || 'SQLite 文件路径') + '</span><input id="webshell-db-sqlite-path" class="form-control" type="text" value="/tmp/test.db" /></label>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="modal-footer"><button type="button" class="btn-secondary" id="webshell-db-profile-cancel-btn">取消</button><button type="button" class="btn-primary" id="webshell-db-profile-save-btn">保存</button></div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'</section>' +
|
||
'</div>' +
|
||
'</div>';
|
||
|
||
// 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 += '<div class="webshell-db-profile-tab' + (active ? ' active' : '') + '" data-id="' + escapeHtml(p.id) + '">' +
|
||
'<button type="button" class="webshell-db-profile-main" data-action="switch" data-id="' + escapeHtml(p.id) + '">' + escapeHtml(p.name || 'DB') + '</button>' +
|
||
'<button type="button" class="webshell-db-profile-menu" data-action="edit" data-id="' + escapeHtml(p.id) + '" title="' + escapeHtml(wsT('webshell.editConnection') || '编辑') + '">⚙</button>' +
|
||
'<button type="button" class="webshell-db-profile-menu" data-action="delete" data-id="' + escapeHtml(p.id) + '" title="' + escapeHtml(wsT('webshell.dbDeleteProfile') || '删除连接') + '">×</button>' +
|
||
'</div>';
|
||
});
|
||
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 = '<div class="webshell-empty">' + escapeHtml(wsT('webshell.dbNoSchema') || '暂无数据库结构,请先加载') + '</div>';
|
||
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 += '<details class="webshell-db-group"' + (isActive ? ' open' : '') + '>';
|
||
html += '<summary class="webshell-db-group-title" data-db="' + escapeHtml(dbName) + '" title="' + escapeHtml(dbName) + '"><span class="webshell-db-icon">🗄</span><span class="webshell-db-label">' + escapeHtml(dbName) + '</span><span class="webshell-db-count">' + tableNames.length + '</span></summary>';
|
||
html += '<div class="webshell-db-group-items">';
|
||
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 += '<details class="webshell-db-table-node" data-db="' + escapeHtml(dbName) + '" data-table="' + escapeHtml(tableName) + '" data-columns-loaded="' + (columns.length ? '1' : '0') + '"' + (tableOpen ? ' open' : '') + '>';
|
||
html += '<summary class="webshell-db-table-item" data-db="' + escapeHtml(dbName) + '" data-table="' + escapeHtml(tableName) + '" title="' + escapeHtml(tableName) + '"><span class="webshell-db-icon">📄</span><span class="webshell-db-label">' + escapeHtml(tableName) + '</span><span class="webshell-db-count">' + escapeHtml(columnCountText) + '</span></summary>';
|
||
if (columns.length) {
|
||
html += '<div class="webshell-db-column-list">';
|
||
columns.forEach(function (columnName) {
|
||
html += '<button type="button" class="webshell-db-column-item" data-db="' + escapeHtml(dbName) + '" data-table="' + escapeHtml(tableName) + '" data-column="' + escapeHtml(columnName) + '" title="' + escapeHtml(columnName) + '"><span class="webshell-db-icon">🧱</span><span class="webshell-db-label">' + escapeHtml(columnName) + '</span></button>';
|
||
});
|
||
html += '</div>';
|
||
} else {
|
||
html += '<div class="webshell-db-column-empty">' + escapeHtml(wsT('webshell.dbNoColumns') || '暂无列信息') + '</div>';
|
||
}
|
||
html += '</details>';
|
||
});
|
||
html += '</div></details>';
|
||
});
|
||
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 = '<span class="webshell-ai-timeline-title">' + escapeHtml(title || message || '') + '</span>';
|
||
|
||
// 工具调用入参
|
||
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 += '<div class="webshell-ai-timeline-msg"><div class="tool-arg-section"><strong>' +
|
||
escapeHtml(paramsLabel) +
|
||
'</strong><pre class="tool-args">' +
|
||
escapeHtml(JSON.stringify(args, null, 2)) +
|
||
'</pre></div></div>';
|
||
}
|
||
} catch (e) {
|
||
// JSON 解析失败时忽略参数详情,避免打断主流程
|
||
}
|
||
} else if (type === 'eino_agent_reply' && message) {
|
||
html += '<div class="webshell-ai-timeline-msg"><pre style="white-space:pre-wrap;">' + escapeHtml(message) + '</pre></div>';
|
||
} 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 += '<div class="webshell-ai-timeline-msg"><div class="tool-result-section ' +
|
||
(isError ? 'error' : 'success') +
|
||
'"><strong>' + escapeHtml(execResultLabel) + '</strong><pre class="tool-result">' +
|
||
escapeHtml(resultStr) +
|
||
'</pre>' +
|
||
(data.executionId ? '<div class="tool-execution-id"><span>' +
|
||
escapeHtml(execIdLabel) +
|
||
'</span> <code>' +
|
||
escapeHtml(String(data.executionId)) +
|
||
'</code></div>' : '') +
|
||
'</div></div>';
|
||
} else if (message && message !== title) {
|
||
html += '<div class="webshell-ai-timeline-msg">' + escapeHtml(message) + '</div>';
|
||
}
|
||
|
||
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 = '<span class="webshell-ai-timeline-title">' + escapeHtml(webshellAgentPx(rdS) + '💬 ' + repTS + ' · ' + runTS) + '</span>';
|
||
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 = '<button type="button" class="webshell-ai-process-toggle" aria-expanded="false">' + escapeHtml(headerLabel) + ' <span class="ws-toggle-icon">▶</span></button><div class="process-details-content"></div>';
|
||
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 = '<p class="terminal-error">' + escapeHtml('未加载 xterm.js,请刷新页面') + '</p>';
|
||
}
|
||
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 = '<div class="webshell-loading">' + wsT('common.refresh') + '...</div>';
|
||
|
||
if (typeof apiFetch === 'undefined') {
|
||
listEl.innerHTML = '<div class="webshell-file-error">apiFetch 未定义</div>';
|
||
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 = '<div class="webshell-file-error">' + escapeHtml(data.error) + '</div><pre class="webshell-file-raw">' + escapeHtml(data.output || '') + '</pre>';
|
||
return;
|
||
}
|
||
listEl.dataset.currentPath = path;
|
||
listEl.dataset.rawOutput = data.output || '';
|
||
renderFileList(listEl, path, data.output || '', conn);
|
||
})
|
||
.catch(function (err) {
|
||
listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(err && err.message ? err.message : wsT('webshell.execError')) + '</div>';
|
||
});
|
||
}
|
||
|
||
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 trimmedLine = String(line || '').trim();
|
||
// `ls -la` 首行常见 "total 12"(中文环境为 "总计 12"),不是文件项。
|
||
if (/^(total|总计)\s+\d+$/i.test(trimmedLine)) continue;
|
||
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('<dir>') !== -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 = '<a href="#" class="webshell-breadcrumb-item" data-path=".">' + (wsT('webshell.breadcrumbHome') || '根') + '</a>' +
|
||
parts.map(function (p, idx) {
|
||
var path = parts.slice(0, idx + 1).join('/');
|
||
return ' / <a href="#" class="webshell-breadcrumb-item" data-path="' + escapeHtml(path) + '">' + escapeHtml(p) + '</a>';
|
||
}).join('');
|
||
}
|
||
renderDirectoryTree(currentPath, items, conn);
|
||
var html = '';
|
||
if (items.length === 0) {
|
||
// 目录为空/过滤后为空时,给出明确空状态,避免 tbody 留白导致“整块抽象大白屏”
|
||
if (rawOutput.trim() && !nameFilter) {
|
||
html = '<pre class="webshell-file-raw">' + escapeHtml(rawOutput) + '</pre>';
|
||
} else {
|
||
html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th class="webshell-col-mtime">' + (wsT('webshell.colModifiedAt') || '修改时间') + '</th><th class="webshell-col-owner">' + (wsT('webshell.colOwner') || '所有者') + '</th><th class="webshell-col-perms">' + (wsT('webshell.colPerms') || '权限') + '</th><th class="webshell-col-actions"></th></tr></thead><tbody>' +
|
||
'<tr><td colspan="7" class="webshell-file-empty-state">' + (wsT('common.noData') || '暂无文件') + '</td></tr>' +
|
||
'</tbody></table>';
|
||
}
|
||
} else {
|
||
html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th class="webshell-col-mtime">' + (wsT('webshell.colModifiedAt') || '修改时间') + '</th><th class="webshell-col-owner">' + (wsT('webshell.colOwner') || '所有者') + '</th><th class="webshell-col-perms">' + (wsT('webshell.colPerms') || '权限') + '</th><th class="webshell-col-actions"></th></tr></thead><tbody>';
|
||
if (currentPath !== '.' && currentPath !== '') {
|
||
html += '<tr><td></td><td><a href="#" class="webshell-file-link" data-path="' + escapeHtml(currentPath.replace(/\/[^/]+$/, '') || '.') + '" data-isdir="1">..</a></td><td></td><td></td><td></td><td></td><td></td></tr>';
|
||
}
|
||
items.forEach(function (item) {
|
||
var pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name;
|
||
var nameClass = item.isDir ? 'is-dir' : 'is-file';
|
||
html += '<tr><td class="webshell-col-check">';
|
||
if (!item.isDir) html += '<input type="checkbox" class="webshell-file-cb" data-path="' + escapeHtml(pathNext) + '" />';
|
||
html += '</td><td><a href="#" class="webshell-file-link ' + nameClass + '" data-path="' + escapeHtml(pathNext) + '" data-isdir="' + (item.isDir ? '1' : '0') + '">' + escapeHtml(item.name) + (item.isDir ? '/' : '') + '</a></td>';
|
||
html += '<td class="webshell-col-size">' + escapeHtml(item.size) + '</td>';
|
||
html += '<td class="webshell-col-mtime">' + escapeHtml(item.mtime || '') + '</td>';
|
||
html += '<td class="webshell-col-owner">' + escapeHtml(item.owner || '') + '</td>';
|
||
html += '<td class="webshell-col-perms">' + escapeHtml(item.mode || '') + '</td>';
|
||
html += '<td class="webshell-col-actions">';
|
||
if (item.isDir) {
|
||
html += '<button type="button" class="btn-ghost btn-sm webshell-file-rename" data-path="' + escapeHtml(pathNext) + '" data-name="' + escapeHtml(item.name) + '">' + (wsT('webshell.rename') || '重命名') + '</button>';
|
||
} else {
|
||
var actionsLabel = wsT('common.actions') || '操作';
|
||
html += '<details class="webshell-row-actions"><summary class="btn-ghost btn-sm webshell-row-actions-btn" title="' + actionsLabel + '">' + actionsLabel + '</summary>' +
|
||
'<div class="webshell-row-actions-menu">' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-file-read" data-path="' + escapeHtml(pathNext) + '">' + wsT('webshell.readFile') + '</button>' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-file-download" data-path="' + escapeHtml(pathNext) + '">' + wsT('webshell.downloadFile') + '</button>' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-file-edit" data-path="' + escapeHtml(pathNext) + '">' + wsT('webshell.editFile') + '</button>' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-file-rename" data-path="' + escapeHtml(pathNext) + '" data-name="' + escapeHtml(item.name) + '">' + (wsT('webshell.rename') || '重命名') + '</button>' +
|
||
'<button type="button" class="btn-ghost btn-sm webshell-file-del" data-path="' + escapeHtml(pathNext) + '">' + wsT('webshell.deleteFile') + '</button>' +
|
||
'</div></details>';
|
||
}
|
||
html += '</td></tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
}
|
||
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 = '<div class="webshell-empty">暂无目录</div>';
|
||
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 =
|
||
'<div class="webshell-tree-node" data-depth="' + depth + '">' +
|
||
'<div class="webshell-tree-row' + (isActive ? ' active' : '') + '">' +
|
||
'<button type="button" class="webshell-tree-toggle' + (canExpand ? '' : ' empty') + '" data-path="' + escapeHtml(path) + '">' + (canExpand ? (isExpanded ? '▾' : '▸') : '·') + '</button>' +
|
||
'<button type="button" class="webshell-dir-item' + (isDir ? ' is-dir' : ' is-file') + '" data-path="' + escapeHtml(path) + '" data-isdir="' + (isDir ? '1' : '0') + '"><span class="webshell-tree-icon">' + icon + '</span><span class="webshell-tree-name">' + escapeHtml(name) + '</span></button>' +
|
||
'</div>';
|
||
if (isDir && hasChildren && isExpanded) {
|
||
nodeHtml += '<div class="webshell-tree-children">';
|
||
for (var j = 0; j < children.length; j++) {
|
||
nodeHtml += renderNode(children[j], depth + 1);
|
||
}
|
||
nodeHtml += '</div>';
|
||
}
|
||
nodeHtml += '</div>';
|
||
return nodeHtml;
|
||
}
|
||
|
||
treeEl.innerHTML = '<div class="webshell-tree-root">' + renderNode({ path: '.', name: '/', isDir: true }, 0) + '</div>';
|
||
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 = '<div class="webshell-loading">' + (wsT('webshell.upload') || '上传') + '...</div>';
|
||
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 = '<div class="webshell-loading">' + wsT('webshell.readFile') + '...</div>';
|
||
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 = '<div class="webshell-file-content"><pre>' + escapeHtml(out) + '</pre><button type="button" class="btn-ghost" id="webshell-file-back-btn" data-back-path="' + escapeHtml(backPath) + '">' + wsT('webshell.back') + '</button></div>';
|
||
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 = '<div class="webshell-file-error">' + escapeHtml(err && err.message ? err.message : '') + '</div>';
|
||
});
|
||
}
|
||
|
||
function webshellFileEdit(conn, path, listEl) {
|
||
if (typeof apiFetch === 'undefined') return;
|
||
listEl.innerHTML = '<div class="webshell-loading">' + wsT('webshell.editFile') + '...</div>';
|
||
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 =
|
||
'<div class="webshell-file-edit-wrap">' +
|
||
'<div class="webshell-file-edit-path">' + escapeHtml(path) + '</div>' +
|
||
'<textarea id="webshell-edit-textarea" class="webshell-file-edit-textarea" rows="18">' + escapeHtml(content) + '</textarea>' +
|
||
'<div class="webshell-file-edit-actions">' +
|
||
'<button type="button" class="btn-primary btn-sm" id="webshell-edit-save">' + wsT('webshell.saveFile') + '</button> ' +
|
||
'<button type="button" class="btn-ghost btn-sm" id="webshell-edit-cancel">' + wsT('webshell.cancelEdit') + '</button>' +
|
||
'</div></div>';
|
||
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 = '<div class="webshell-file-error">' + escapeHtml(err && err.message ? err.message : '') + '</div>';
|
||
});
|
||
}
|
||
|
||
function webshellFileWrite(conn, path, content, onDone, listEl) {
|
||
if (typeof apiFetch === 'undefined') return;
|
||
if (listEl) listEl.innerHTML = '<div class="webshell-loading">' + wsT('webshell.saveFile') + '...</div>';
|
||
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 = '<div class="webshell-file-error">' + escapeHtml(data.error) + '</div><pre class="webshell-file-raw">' + escapeHtml(data.output || '') + '</pre>';
|
||
return;
|
||
}
|
||
if (onDone) onDone();
|
||
})
|
||
.catch(function (err) {
|
||
if (listEl) listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(err && err.message ? err.message : wsT('webshell.execError')) + '</div>';
|
||
});
|
||
}
|
||
|
||
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 = '<div class="webshell-workspace-placeholder">' + wsT('webshell.selectOrAdd') + '</div>';
|
||
}
|
||
})
|
||
.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 = '<div class="webshell-workspace-placeholder" data-i18n="webshell.selectOrAdd">' + wsT('webshell.selectOrAdd') + '</div>';
|
||
} 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 = '<div class="webshell-empty">' + escapeHtml(wsT('webshell.dbNoSchema') || '暂无数据库结构,请先加载') + '</div>';
|
||
}
|
||
|
||
// 如果当前 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 : '保存失败');
|
||
});
|
||
}
|