From 97b7b4b932c694e24ef84cab207127966248893b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:54:38 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 153 +++++++++++++++++++- web/static/js/webshell.js | 296 +++++++++++++++++++++++++++++++------- 2 files changed, 400 insertions(+), 49 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index a8403ddf..366739b3 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -8941,6 +8941,128 @@ header { background: rgba(139, 148, 158, 0.7); } +.webshell-file-layout { + display: flex; + gap: 12px; + min-height: 0; + flex: 1; +} + +.webshell-file-sidebar { + width: 280px; + min-width: 260px; + max-width: 320px; + border: 1px solid var(--border-color); + border-radius: 10px; + background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); + display: flex; + flex-direction: column; + min-height: 0; + box-shadow: var(--shadow-sm); +} + +.webshell-file-sidebar-title { + padding: 12px 14px; + border-bottom: 1px solid var(--border-color); + font-weight: 600; + color: var(--text-primary); + letter-spacing: 0.2px; +} + +.webshell-dir-tree { + padding: 10px 8px; + overflow: auto; + min-height: 0; +} + +.webshell-tree-node { + position: relative; +} + +.webshell-tree-row { + display: flex; + align-items: center; + border-radius: 8px; + margin: 2px 0; +} + +.webshell-tree-row.active { + background: rgba(0, 102, 255, 0.11); +} + +.webshell-tree-toggle { + width: 18px; + min-width: 18px; + height: 24px; + margin-left: 2px; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + padding: 0; + font-size: 12px; +} + +.webshell-tree-toggle.empty { + cursor: default; + opacity: 0.6; +} + +.webshell-tree-children { + margin-left: 14px; + border-left: 1px dashed rgba(128, 128, 128, 0.28); + padding-left: 6px; +} + +.webshell-dir-item { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 1; + text-align: left; + border: none; + background: transparent; + color: var(--text-primary); + border-radius: 6px; + padding: 4px 8px; + margin: 0; + cursor: pointer; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.webshell-tree-icon { + flex: 0 0 auto; + font-size: 0.92rem; + opacity: 0.92; +} + +.webshell-tree-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.webshell-dir-item:hover { + background: rgba(0, 102, 255, 0.08); +} + +.webshell-tree-row.active .webshell-dir-item { + color: var(--accent-color); + font-weight: 600; +} + +.webshell-file-main { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + flex: 1; +} + .webshell-file-toolbar { display: flex; align-items: center; @@ -8948,7 +9070,7 @@ header { margin-bottom: 12px; flex-wrap: wrap; padding: 12px 14px; - background: var(--bg-secondary); + background: linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); border-radius: 10px; border: 1px solid var(--border-color); width: 100%; @@ -9093,6 +9215,19 @@ header { width: 100%; } +@media (max-width: 1200px) { + .webshell-file-layout { + flex-direction: column; + } + + .webshell-file-sidebar { + width: 100%; + min-width: 0; + max-width: none; + max-height: 220px; + } +} + .webshell-file-table { width: 100%; border-collapse: collapse; @@ -9154,6 +9289,22 @@ header { color: var(--accent-hover); } +.webshell-file-link.is-dir::before, +.webshell-file-link.is-file::before { + display: inline-block; + margin-right: 6px; + font-size: 0.95rem; + opacity: 0.95; +} + +.webshell-file-link.is-dir::before { + content: "📁"; +} + +.webshell-file-link.is-file::before { + content: "📄"; +} + .webshell-file-table .webshell-file-read { color: var(--accent-color); margin-right: 8px; diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index 78185c0b..a9f057f2 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -24,6 +24,9 @@ let webshellClearInProgress = false; let webshellAiConvMap = {}; let webshellAiSending = false; let webshellDbConfigByConn = {}; +let webshellDirTreeByConn = {}; +let webshellDirExpandedByConn = {}; +let webshellDirLoadedByConn = {}; // 流式打字机效果:当前会话的 response 序号,用于中止过期的打字 let webshellStreamingTypingId = 0; let webshellProbeStatusById = {}; @@ -140,6 +143,7 @@ function wsT(key) { 'webshell.refresh': '刷新', 'webshell.selectAll': '全选', 'webshell.breadcrumbHome': '根', + 'webshell.dirTree': '目录列表', 'webshell.searchPlaceholder': '搜索连接...', 'webshell.noMatchConnections': '暂无匹配连接', 'webshell.batchProbe': '一键批量探活', @@ -160,6 +164,12 @@ function wsT(key) { 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; @@ -508,6 +518,29 @@ function safeConnIdForStorage(conn) { 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 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 getWebshellDbConfig(conn) { var key = 'webshell_db_cfg_' + safeConnIdForStorage(conn); if (!key) return { @@ -918,6 +951,12 @@ function selectWebshell(id) { '
' + '' + '
' + + '
' + + '' + + '
' + '
' + '
' + '
' + @@ -940,6 +979,8 @@ function selectWebshell(id) { '
' + '
' + '
' + + '
' + + '
' + '
' + '
' + '
' + @@ -1799,43 +1840,40 @@ function webshellFileListDir(conn, path) { }); } -function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { - function normalizeLsMtime(month, day, timeOrYear) { - if (!month || !day || !timeOrYear) return ''; - var token = String(timeOrYear).trim(); - if (/^\d{4}$/.test(token)) { - return token + ' ' + month + ' ' + day; +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; } - var now = new Date(); - var year = now.getFullYear(); - if (/^\d{1,2}:\d{2}$/.test(token)) { - // ls -l 在半年内通常只显示 HH:MM;推断年份(避免未来日期) - 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; + 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 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; +} - var lines = rawOutput.split(/\n/).filter(function (l) { return l.trim(); }); +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]; @@ -1847,9 +1885,6 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { var owner = ''; var group = ''; var type = ''; - - // 兼容典型:ls -la 输出(mode links owner group size month day time|year name) - // 示例:-rw-r--r-- 1 user group 1234 Mar 23 12:34 file.txt 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]; @@ -1861,7 +1896,6 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { 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; @@ -1870,17 +1904,40 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { 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] || ''; } - // 尝试解析 mtime:month day (time|year) - if (parts.length >= 8 && /^[A-Za-z]{3}$/.test(parts[5])) { - mtime = normalizeLsMtime(parts[5], parts[6], parts[7]); - } + 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; }); @@ -1895,32 +1952,33 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { return ' / ' + escapeHtml(p) + ''; }).join(''); } + renderDirectoryTree(currentPath, items, conn); var html = ''; if (items.length === 0) { // 目录为空/过滤后为空时,给出明确空状态,避免 tbody 留白导致“整块抽象大白屏” if (rawOutput.trim() && !nameFilter) { html = '
' + escapeHtml(rawOutput) + '
'; } else { - html = '' + - '' + + html = '
' + wsT('webshell.filePath') + '大小' + (wsT('webshell.colModifiedAt') || '修改时间') + '' + (wsT('webshell.colOwner') || '所有者') + '' + (wsT('webshell.colGroup') || '用户组') + '' + (wsT('webshell.colPerms') || '权限') + '' + (wsT('webshell.colType') || '类型') + '
' + (wsT('common.noData') || '暂无文件') + '
' + + '' + '
' + wsT('webshell.filePath') + '大小' + (wsT('webshell.colModifiedAt') || '修改时间') + '' + (wsT('webshell.colOwner') || '所有者') + '' + (wsT('webshell.colGroup') || '用户组') + '' + (wsT('webshell.colPerms') || '权限') + '
' + (wsT('common.noData') || '暂无文件') + '
'; } } else { - html = ''; + html = '
' + wsT('webshell.filePath') + '大小' + (wsT('webshell.colModifiedAt') || '修改时间') + '' + (wsT('webshell.colOwner') || '所有者') + '' + (wsT('webshell.colGroup') || '用户组') + '' + (wsT('webshell.colPerms') || '权限') + '' + (wsT('webshell.colType') || '类型') + '
'; if (currentPath !== '.' && currentPath !== '') { - html += ''; + html += ''; } items.forEach(function (item) { var pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name; + var nameClass = item.isDir ? 'is-dir' : 'is-file'; html += ''; + html += ''; html += ''; html += ''; html += ''; html += ''; html += ''; - html += ''; html += '
' + wsT('webshell.filePath') + '大小' + (wsT('webshell.colModifiedAt') || '修改时间') + '' + (wsT('webshell.colOwner') || '所有者') + '' + (wsT('webshell.colGroup') || '用户组') + '' + (wsT('webshell.colPerms') || '权限') + '
..
..
'; if (!item.isDir) html += ''; - html += '' + escapeHtml(item.name) + (item.isDir ? '/' : '') + '' + escapeHtml(item.name) + (item.isDir ? '/' : '') + '' + escapeHtml(item.size) + '' + escapeHtml(item.mtime || '') + '' + escapeHtml(item.owner || '') + '' + escapeHtml(item.group || '') + '' + escapeHtml(item.mode || '') + '' + escapeHtml(item.type || '') + ''; if (item.isDir) { html += ''; @@ -2008,6 +2066,148 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) { } } +function renderDirectoryTree(currentPath, items, conn) { + var treeEl = document.getElementById('webshell-dir-tree'); + if (!treeEl) return; + var state = getWebshellTreeState(conn || webshellCurrentConn); + var curr = normalizeWebshellPath(currentPath); + var dirs = (items || []).filter(function (item) { return item && item.isDir; }); + if (!state) { + treeEl.innerHTML = '
暂无目录
'; + return; + } + var tree = state.tree; + var expanded = state.expanded; + var loaded = state.loaded; + if (!tree['.']) tree['.'] = []; + if (expanded['.'] !== false) expanded['.'] = true; + + // 把当前目录的子项(目录+文件)同步到树缓存 + var childNodes = (items || []).map(function (item) { + var childPath = curr === '.' ? normalizeWebshellPath(item.name) : normalizeWebshellPath(curr + '/' + item.name); + return { + path: childPath, + name: item.name, + isDir: !!item.isDir + }; + }).filter(function (n) { return !!n.path; }); + childNodes.sort(function (a, b) { + // 目录优先,再按名称排序 + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return (a.name || '').localeCompare(b.name || ''); + }); + tree[curr] = childNodes; + loaded[curr] = true; + childNodes.forEach(function (node) { + if (node.isDir && !tree[node.path]) tree[node.path] = []; + }); + + // 确保当前路径祖先链存在并展开 + var parts = curr === '.' ? [] : curr.split('/'); + var parentPath = '.'; + for (var i = 0; i < parts.length; i++) { + var nextPath = parentPath === '.' ? parts[i] : parentPath + '/' + parts[i]; + if (!tree[parentPath]) tree[parentPath] = []; + var parentChildren = tree[parentPath]; + var hasAncestorNode = parentChildren.some(function (n) { return n && n.path === nextPath; }); + if (!hasAncestorNode) { + parentChildren.push({ path: nextPath, name: parts[i], isDir: true }); + parentChildren.sort(function (a, b) { + if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1; + return (a.name || '').localeCompare(b.name || ''); + }); + } + if (!tree[nextPath]) tree[nextPath] = []; + expanded[parentPath] = true; + parentPath = nextPath; + } + expanded[curr] = true; + + function renderNode(node, depth) { + var path = node.path; + var isDir = !!node.isDir; + var children = isDir ? (tree[path] || []).slice() : []; + var hasLoadedChildren = isDir ? (loaded[path] === true) : true; + var canExpand = isDir && (path === '.' || !hasLoadedChildren || children.length > 0); + var hasChildren = children.length > 0; + var isExpanded = isDir ? (expanded[path] !== false) : false; + var isActive = path === curr; + var name = node.name; + var icon = isDir ? (path === '.' ? '🗂' : '📁') : '📄'; + var nodeHtml = + '
' + + '
' + + '' + + '' + + '
'; + if (isDir && hasChildren && isExpanded) { + nodeHtml += '
'; + for (var j = 0; j < children.length; j++) { + nodeHtml += renderNode(children[j], depth + 1); + } + nodeHtml += '
'; + } + nodeHtml += '
'; + return nodeHtml; + } + + treeEl.innerHTML = '
' + renderNode({ path: '.', name: '/', isDir: true }, 0) + '
'; + treeEl.querySelectorAll('.webshell-tree-toggle').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + var p = normalizeWebshellPath(btn.getAttribute('data-path') || '.'); + if (expanded[p] !== false) { + expanded[p] = false; + renderDirectoryTree(curr, items, conn || webshellCurrentConn); + return; + } + if (loaded[p] === true) { + expanded[p] = true; + renderDirectoryTree(curr, items, conn || webshellCurrentConn); + return; + } + fetchWebshellDirectoryItems(conn || webshellCurrentConn, p).then(function (subItems) { + var nextChildren = (subItems || []).map(function (it) { + return { + path: p === '.' ? normalizeWebshellPath(it.name) : normalizeWebshellPath(p + '/' + it.name), + name: it.name, + isDir: !!it.isDir + }; + }).filter(function (n) { return !!n.path; }).sort(function (a, b) { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return (a.name || '').localeCompare(b.name || ''); + }); + tree[p] = nextChildren; + nextChildren.forEach(function (childNode) { + if (childNode.isDir) { + if (!tree[childNode.path]) tree[childNode.path] = []; + if (loaded[childNode.path] == null) loaded[childNode.path] = false; + } + }); + loaded[p] = true; + expanded[p] = true; + renderDirectoryTree(curr, items, conn || webshellCurrentConn); + }); + }); + }); + treeEl.querySelectorAll('.webshell-dir-item').forEach(function (btn) { + btn.addEventListener('click', function () { + var p = normalizeWebshellPath(btn.getAttribute('data-path') || '.'); + var isDir = btn.getAttribute('data-isdir') === '1'; + var pathInput = document.getElementById('webshell-file-path'); + if (isDir) { + if (pathInput) pathInput.value = p; + webshellFileListDir(webshellCurrentConn, p); + return; + } + var listEl = document.getElementById('webshell-file-list'); + var browsePath = p.replace(/\/[^/]+$/, '') || '.'; + if (listEl) webshellFileRead(webshellCurrentConn, p, listEl, browsePath); + }); + }); +} + function webshellFileListApplyFilter() { var listEl = document.getElementById('webshell-file-list'); var path = listEl && listEl.dataset.currentPath ? listEl.dataset.currentPath : (document.getElementById('webshell-file-path') && document.getElementById('webshell-file-path').value.trim()) || '.';