From 39926007fecc7574f93c1b1958feb664a6d95046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Fri, 1 May 2026 01:01:30 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 575 ++++++++---- web/static/i18n/en-US.json | 11 + web/static/i18n/zh-CN.json | 11 + web/static/js/chat.js | 1774 +++++++++++++++++++++++------------- web/static/js/webshell.js | 177 +++- web/templates/index.html | 19 + 6 files changed, 1713 insertions(+), 854 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index 134b6e4b..cd492209 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -6172,9 +6172,12 @@ header { max-height: 90vh; display: flex; flex-direction: column; - border-radius: 20px; + border-radius: 18px; overflow: hidden; - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.05); + box-shadow: + 0 32px 96px rgba(15, 23, 42, 0.28), + 0 8px 24px rgba(15, 23, 42, 0.1), + 0 0 0 1px rgba(15, 23, 42, 0.06); background: #ffffff; } @@ -6184,7 +6187,10 @@ header { flex: 1; overflow: hidden; padding: 0; - background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%); + background: + radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.06) 0%, transparent 60%), + radial-gradient(1000px 500px at 100% 110%, rgba(14, 165, 233, 0.05) 0%, transparent 60%), + linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%); } .attack-chain-main-layout { @@ -6205,10 +6211,13 @@ header { } .attack-chain-sidebar { - width: 280px; + width: 300px; flex-shrink: 0; - background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%); - border-left: 1px solid rgba(0, 0, 0, 0.06); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.85) 0%, rgba(248, 250, 252, 0.9) 100%); + backdrop-filter: saturate(1.1) blur(6px); + -webkit-backdrop-filter: saturate(1.1) blur(6px); + border-left: 1px solid rgba(15, 23, 42, 0.07); display: flex; flex-direction: column; overflow: hidden; @@ -6224,43 +6233,67 @@ header { -webkit-overflow-scrolling: touch; } +.attack-chain-sidebar-content::-webkit-scrollbar { + width: 6px; +} +.attack-chain-sidebar-content::-webkit-scrollbar-track { + background: transparent; +} +.attack-chain-sidebar-content::-webkit-scrollbar-thumb { + background: rgba(15, 23, 42, 0.15); + border-radius: 3px; +} +.attack-chain-sidebar-content::-webkit-scrollbar-thumb:hover { + background: rgba(15, 23, 42, 0.3); +} + .attack-chain-toolbar { - padding: 12px 16px; - border-bottom: 1px solid rgba(0, 0, 0, 0.06); - background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%); + padding: 14px 18px; + border-bottom: 1px solid rgba(15, 23, 42, 0.07); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 252, 0.92) 100%); + backdrop-filter: saturate(1.1) blur(6px); + -webkit-backdrop-filter: saturate(1.1) blur(6px); display: flex; align-items: center; - gap: 16px; + gap: 14px; flex-shrink: 0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04); } .attack-chain-info { font-size: 0.8125rem; - color: var(--text-primary); + color: #1e293b; font-weight: 600; - padding: 6px 12px; - background: linear-gradient(135deg, rgba(0, 102, 255, 0.1) 0%, rgba(0, 102, 255, 0.06) 100%); - border: 1px solid rgba(0, 102, 255, 0.2); - border-radius: 6px; - box-shadow: 0 1px 3px rgba(0, 102, 255, 0.08); + padding: 7px 14px; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.08) 100%); + border: 1px solid rgba(59, 130, 246, 0.22); + border-radius: 999px; + box-shadow: 0 1px 2px rgba(59, 130, 246, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.6); display: inline-flex; align-items: center; - gap: 6px; + gap: 7px; white-space: nowrap; flex-shrink: 0; + letter-spacing: 0.2px; } .attack-chain-info::before { - content: '📊'; - font-size: 0.8125rem; + content: ''; + width: 14px; + height: 14px; + border-radius: 4px; + background: + linear-gradient(135deg, #3b82f6 0%, #6366f1 100%); + box-shadow: 0 1px 3px rgba(59, 130, 246, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.35); + flex-shrink: 0; } .attack-chain-legend { display: flex; flex-direction: column; - gap: 20px; - padding: 16px; + gap: 18px; + padding: 18px 16px 20px; background: transparent; flex-shrink: 0; } @@ -6271,58 +6304,89 @@ header { gap: 10px; font-size: 0.8125rem; font-weight: 500; - color: var(--text-primary); - padding: 4px 0; - transition: all 0.2s ease; + color: #334155; + padding: 5px 8px; + border-radius: 8px; + transition: background 0.2s ease, transform 0.2s ease; } .legend-item:hover { + background: rgba(99, 102, 241, 0.06); transform: translateX(2px); } .legend-color { - width: 20px; - height: 20px; - border-radius: 6px; - border: 1.5px solid rgba(255, 255, 255, 0.9); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12), inset 0 1px 2px rgba(255, 255, 255, 0.3); - transition: all 0.2s ease; + width: 22px; + height: 22px; + border-radius: 7px; + border: 1.5px solid rgba(255, 255, 255, 0.95); + box-shadow: + 0 2px 6px rgba(15, 23, 42, 0.18), + inset 0 1px 2px rgba(255, 255, 255, 0.5); + transition: transform 0.2s ease, box-shadow 0.2s ease; flex-shrink: 0; + position: relative; +} + +.legend-color::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient(180deg, rgba(255,255,255,0.35) 0%, transparent 60%); + pointer-events: none; } .legend-item:hover .legend-color { - transform: scale(1.15); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2), inset 0 1px 2px rgba(255, 255, 255, 0.3); + transform: scale(1.12); + box-shadow: 0 6px 14px rgba(15, 23, 42, 0.25), inset 0 1px 2px rgba(255, 255, 255, 0.4); } .legend-section { display: flex; flex-direction: column; - gap: 8px; + gap: 4px; width: 100%; + padding: 12px 12px 14px; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(15, 23, 42, 0.06); + border-radius: 12px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03); } .legend-title { - font-size: 0.75rem; + font-size: 0.7rem; font-weight: 700; - color: var(--text-primary); + color: #0f172a; text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 6px; - padding-bottom: 6px; - border-bottom: 1px solid rgba(0, 0, 0, 0.08); + letter-spacing: 0.8px; + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px dashed rgba(15, 23, 42, 0.08); + display: flex; + align-items: center; + gap: 8px; +} + +.legend-title::before { + content: ''; + display: inline-block; + width: 4px; + height: 14px; + border-radius: 2px; + background: linear-gradient(180deg, #3b82f6 0%, #6366f1 100%); } .legend-line { display: inline-block; - width: 32px; + width: 34px; height: 0; margin-right: 0; vertical-align: middle; border-radius: 2px; flex-shrink: 0; - transition: all 0.2s ease; + transition: transform 0.2s ease; } .legend-item:hover .legend-line { @@ -6332,28 +6396,44 @@ header { .attack-chain-container { flex: 1; min-height: 0; - background: #ffffff; - border: none; + background: + linear-gradient(180deg, #fbfcfe 0%, #f6f8fc 100%); + border: 1px solid rgba(15, 23, 42, 0.06); position: relative; overflow: hidden; - margin: 12px; - border-radius: 12px; - box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.06); + margin: 14px 14px 14px 14px; + border-radius: 14px; + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.03), + inset 0 1px 0 rgba(255, 255, 255, 0.75); } .attack-chain-container::before { content: ''; position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: - radial-gradient(circle at 20% 30%, rgba(0, 102, 255, 0.02) 0%, transparent 50%), - radial-gradient(circle at 80% 70%, rgba(0, 102, 255, 0.02) 0%, transparent 50%); + inset: 0; + background-image: + radial-gradient(circle at center, rgba(15, 23, 42, 0.07) 1px, transparent 1.5px); + background-size: 22px 22px; + background-position: 0 0; + opacity: 0.55; pointer-events: none; z-index: 0; - border-radius: 12px; + border-radius: 14px; + mask-image: radial-gradient(ellipse at center, black 60%, transparent 100%); + -webkit-mask-image: radial-gradient(ellipse at center, black 60%, transparent 100%); +} + +.attack-chain-container::after { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(800px 400px at 15% 12%, rgba(59, 130, 246, 0.06) 0%, transparent 70%), + radial-gradient(700px 360px at 85% 88%, rgba(168, 85, 247, 0.05) 0%, transparent 70%); + pointer-events: none; + z-index: 0; + border-radius: 14px; } /* 攻击链筛选器样式 */ @@ -6367,128 +6447,202 @@ header { } .attack-chain-filters input[type="text"] { - padding: 6px 12px; - border: 1.5px solid rgba(0, 0, 0, 0.1); - border-radius: 6px; + padding: 7px 14px 7px 34px; + border: 1.5px solid rgba(15, 23, 42, 0.1); + border-radius: 10px; font-size: 0.8125rem; - min-width: 180px; + min-width: 200px; flex: 1; - max-width: 300px; - background: #ffffff; - color: var(--text-primary); - transition: all 0.25s ease; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + max-width: 320px; + background: + #ffffff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cpath d='m21 21-4.3-4.3'/%3E%3C/svg%3E") no-repeat 10px center; + color: #0f172a; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); } .attack-chain-filters input[type="text"]:focus { outline: none; - border-color: var(--accent-color); - box-shadow: 0 0 0 4px rgba(0, 102, 255, 0.12), 0 2px 6px rgba(0, 102, 255, 0.1); - transform: translateY(-1px); + border-color: #6366f1; + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.15), 0 2px 6px rgba(99, 102, 241, 0.12); } .attack-chain-filters input[type="text"]:hover { - border-color: rgba(0, 102, 255, 0.3); + border-color: rgba(99, 102, 241, 0.4); } .attack-chain-filters input[type="text"]::placeholder { - color: var(--text-muted); + color: #94a3b8; } .attack-chain-filters select { - padding: 6px 12px; - padding-right: 32px; - border: 1.5px solid rgba(0, 0, 0, 0.1); - border-radius: 6px; + padding: 7px 34px 7px 14px; + border: 1.5px solid rgba(15, 23, 42, 0.1); + border-radius: 10px; font-size: 0.8125rem; background: #ffffff; - color: var(--text-primary); + color: #0f172a; cursor: pointer; - transition: all 0.25s ease; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23333' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; font-weight: 500; - min-width: 120px; + min-width: 130px; } .attack-chain-filters select:focus { outline: none; - border-color: var(--accent-color); - box-shadow: 0 0 0 4px rgba(0, 102, 255, 0.12), 0 2px 6px rgba(0, 102, 255, 0.1); - transform: translateY(-1px); + border-color: #6366f1; + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.15), 0 2px 6px rgba(99, 102, 241, 0.12); } .attack-chain-filters select:hover { - border-color: rgba(0, 102, 255, 0.3); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); + border-color: rgba(99, 102, 241, 0.4); + box-shadow: 0 2px 4px rgba(15, 23, 42, 0.08); } .attack-chain-filters button.btn-secondary { - padding: 6px 14px; + padding: 7px 16px; font-size: 0.8125rem; font-weight: 600; - border: 1.5px solid rgba(0, 0, 0, 0.1); - border-radius: 6px; + border: 1.5px solid rgba(15, 23, 42, 0.1); + border-radius: 10px; background: #ffffff; - color: var(--text-primary); + color: #0f172a; cursor: pointer; - transition: all 0.25s ease; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); white-space: nowrap; } .attack-chain-filters button.btn-secondary:hover { - background: linear-gradient(135deg, rgba(0, 102, 255, 0.08) 0%, rgba(0, 102, 255, 0.04) 100%); - border-color: var(--accent-color); - color: var(--accent-color); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 102, 255, 0.15); + background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(59, 130, 246, 0.06) 100%); + border-color: #6366f1; + color: #4338ca; + transform: translateY(-1px); + box-shadow: 0 6px 14px rgba(99, 102, 241, 0.18); } .attack-chain-action-btn { - padding: 10px 18px !important; - font-size: 0.875rem !important; + padding: 9px 16px !important; + font-size: 0.8125rem !important; font-weight: 600 !important; border-radius: 10px !important; - transition: all 0.25s ease !important; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08) !important; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease !important; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08) !important; + letter-spacing: 0.2px; } .attack-chain-action-btn:hover { - transform: translateY(-2px) !important; - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12) !important; + transform: translateY(-1px) !important; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.14) !important; } .modal-header { - padding: 16px 20px !important; - border-bottom: 1px solid rgba(0, 0, 0, 0.06) !important; - background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%) !important; + padding: 16px 22px !important; + border-bottom: 1px solid rgba(15, 23, 42, 0.07) !important; + background: + linear-gradient(180deg, #ffffff 0%, #f8fafc 100%) !important; flex-shrink: 0; + position: relative; +} + +.modal-header::after { + content: ''; + position: absolute; + left: 22px; + right: 22px; + bottom: -1px; + height: 1px; + background: linear-gradient(90deg, transparent 0%, rgba(99, 102, 241, 0.25) 50%, transparent 100%); + pointer-events: none; } .modal-header h2 { - font-size: 1.25rem !important; + font-size: 1.2rem !important; font-weight: 700 !important; letter-spacing: -0.3px !important; - background: linear-gradient(135deg, #1a1a1a 0%, #4a4a4a 100%); + background: linear-gradient(135deg, #0f172a 0%, #334155 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; + display: inline-flex; + align-items: center; + gap: 10px; +} + +.modal-header h2::before { + content: ''; + display: inline-block; + width: 6px; + height: 22px; + border-radius: 3px; + background: linear-gradient(180deg, #3b82f6 0%, #6366f1 60%, #a855f7 100%); + box-shadow: 0 2px 6px rgba(99, 102, 241, 0.4); + -webkit-text-fill-color: initial; } .attack-chain-details { display: flex !important; flex-direction: column; gap: 12px; - margin-top: 8px; - padding-top: 20px; - border-top: 1px solid rgba(0, 0, 0, 0.08); + margin: 4px 16px 16px; + padding: 16px; + background: linear-gradient(180deg, #ffffff 0%, #fafbff 100%); + border: 1px solid rgba(99, 102, 241, 0.15); + border-radius: 14px; + box-shadow: + 0 4px 14px rgba(15, 23, 42, 0.06), + 0 1px 2px rgba(15, 23, 42, 0.03); transition: opacity 0.2s ease; overflow: hidden; will-change: opacity; + position: relative; +} + +/* ========== 节点详情独占态:点击节点后详情占满 sidebar,图例隐藏 ========== */ +/* 激活态:隐藏图例的两个辅助 section(风险等级、连线含义),保留详情 */ +.attack-chain-sidebar.details-active + .attack-chain-legend > .legend-section:not(.attack-chain-details) { + display: none !important; +} + +/* 激活态:让 legend 容器成为可撑满的 flex 列,详情占满剩余空间 */ +.attack-chain-sidebar.details-active .attack-chain-legend { + flex: 1 1 auto; + min-height: 0; + padding: 0; + gap: 0; + display: flex; +} + +/* 激活态:详情面板填满 sidebar 全高 */ +.attack-chain-sidebar.details-active .attack-chain-details { + flex: 1 1 auto; + min-height: 0; + margin: 14px 14px 16px; + border-radius: 14px; +} + +/* 激活态:详情内容区占满剩余空间可滚动 */ +.attack-chain-sidebar.details-active .attack-chain-details-content { + flex: 1 1 auto; + min-height: 0; + max-height: none; +} + +.attack-chain-details::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, #3b82f6 0%, #6366f1 50%, #a855f7 100%); + border-radius: 14px 14px 0 0; } .attack-chain-details[style*="display: none"] { @@ -6515,8 +6669,8 @@ header { justify-content: space-between; align-items: center; margin-bottom: 0 !important; - padding-bottom: 8px !important; - border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important; + padding-bottom: 10px !important; + border-bottom: 1px dashed rgba(15, 23, 42, 0.1) !important; } .attack-chain-details-title span { @@ -6524,32 +6678,33 @@ header { } .attack-chain-details-close { - width: 24px; - height: 24px; + width: 26px; + height: 26px; display: flex; align-items: center; justify-content: center; - border-radius: 4px; + border-radius: 8px; cursor: pointer; - color: var(--text-secondary); + color: #64748b; border: 1px solid transparent; - background: transparent; + background: rgba(15, 23, 42, 0.04); transition: all 0.2s ease; padding: 0; flex-shrink: 0; - opacity: 0.6; + opacity: 0.75; } .attack-chain-details-close:hover { - background: rgba(0, 0, 0, 0.06); - color: var(--text-primary); + background: rgba(239, 68, 68, 0.1); + color: #ef4444; opacity: 1; + transform: rotate(90deg); } .attack-chain-details-content { display: flex; flex-direction: column; - gap: 12px; + gap: 10px; max-height: 600px; overflow-y: auto; overflow-x: hidden; @@ -6563,69 +6718,73 @@ header { } .attack-chain-details-content::-webkit-scrollbar-track { - background: rgba(0, 0, 0, 0.02); + background: transparent; border-radius: 3px; } .attack-chain-details-content::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.15); + background: rgba(15, 23, 42, 0.15); border-radius: 3px; } .attack-chain-details-content::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.25); + background: rgba(15, 23, 42, 0.3); } .node-detail-item { margin-bottom: 0; font-size: 0.8125rem; - line-height: 1.6; - padding: 10px 12px; - background: rgba(0, 0, 0, 0.02); - border-radius: 6px; - border: 1px solid rgba(0, 0, 0, 0.05); - transition: all 0.2s ease; + line-height: 1.65; + padding: 10px 14px; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.06); + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; + color: #1e293b; } .node-detail-item:hover { - background: rgba(0, 0, 0, 0.04); - border-color: rgba(0, 102, 255, 0.2); + border-color: rgba(99, 102, 241, 0.3); + box-shadow: 0 3px 10px rgba(99, 102, 241, 0.08); + transform: translateY(-1px); } .node-detail-item strong { display: block; margin-bottom: 6px; - color: var(--text-primary); - font-weight: 600; - font-size: 0.75rem; + color: #64748b; + font-weight: 700; + font-size: 0.68rem; text-transform: uppercase; - letter-spacing: 0.5px; - color: var(--text-secondary); + letter-spacing: 0.8px; } .node-detail-item code { - background: rgba(0, 0, 0, 0.06); + background: rgba(99, 102, 241, 0.08); + color: #4338ca; padding: 3px 8px; - border-radius: 4px; - font-size: 0.8125rem; + border-radius: 5px; + font-size: 0.8rem; word-break: break-all; display: inline-block; max-width: 100%; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - color: var(--text-primary); + font-family: 'SFMono-Regular', 'Monaco', 'Menlo', 'Consolas', monospace; + border: 1px solid rgba(99, 102, 241, 0.15); } .node-detail-item pre { - background: rgba(0, 0, 0, 0.06); - padding: 10px; - border-radius: 6px; - font-size: 0.75rem; + background: #0f172a; + color: #e2e8f0; + padding: 12px; + border-radius: 8px; + font-size: 0.78rem; overflow-x: auto; margin: 8px 0 0 0; - line-height: 1.5; + line-height: 1.6; max-width: 100%; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; - border: 1px solid rgba(0, 0, 0, 0.08); + font-family: 'SFMono-Regular', 'Monaco', 'Menlo', 'Consolas', monospace; + border: 1px solid rgba(15, 23, 42, 0.2); + box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.2); } .node-detail-item ul { @@ -6683,7 +6842,7 @@ header { .modal-header-actions { display: flex; - gap: 12px; + gap: 10px; align-items: center; flex-wrap: wrap; } @@ -6692,46 +6851,86 @@ header { padding: 8px 16px !important; font-size: 0.8125rem !important; font-weight: 600 !important; - border-radius: 8px !important; - transition: all 0.25s ease !important; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08) !important; - border: 2px solid transparent !important; + border-radius: 10px !important; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease !important; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06) !important; + border: 1.5px solid transparent !important; display: inline-flex !important; align-items: center !important; - gap: 6px !important; + gap: 7px !important; + letter-spacing: 0.2px; + position: relative; + overflow: hidden; +} + +.attack-chain-action-btn::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255,255,255,0.15) 0%, transparent 100%); + pointer-events: none; } .attack-chain-action-btn:hover { - transform: translateY(-2px) !important; - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12) !important; + transform: translateY(-1px) !important; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12) !important; +} + +.attack-chain-action-btn:active { + transform: translateY(0) !important; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.1) !important; } .attack-chain-action-btn.btn-primary { - background: linear-gradient(135deg, #0066ff 0%, #0052cc 100%) !important; - color: white !important; - border-color: #0066ff !important; + background: linear-gradient(135deg, #3b82f6 0%, #6366f1 50%, #8b5cf6 100%) !important; + color: #ffffff !important; + border-color: transparent !important; + box-shadow: + 0 4px 14px rgba(99, 102, 241, 0.35), + inset 0 1px 0 rgba(255, 255, 255, 0.25) !important; } .attack-chain-action-btn.btn-primary:hover { - background: linear-gradient(135deg, #0052cc 0%, #0040a3 100%) !important; - box-shadow: 0 6px 20px rgba(0, 102, 255, 0.3) !important; + background: linear-gradient(135deg, #2563eb 0%, #4f46e5 50%, #7c3aed 100%) !important; + box-shadow: + 0 10px 22px rgba(99, 102, 241, 0.42), + inset 0 1px 0 rgba(255, 255, 255, 0.3) !important; } .attack-chain-action-btn.btn-secondary { - background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%) !important; - color: var(--text-primary) !important; - border-color: rgba(0, 0, 0, 0.1) !important; + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%) !important; + color: #334155 !important; + border-color: rgba(15, 23, 42, 0.1) !important; } .attack-chain-action-btn.btn-secondary:hover { - background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%) !important; - border-color: var(--accent-color) !important; - color: var(--accent-color) !important; + background: linear-gradient(180deg, #eef2ff 0%, #e0e7ff 100%) !important; + border-color: #6366f1 !important; + color: #4338ca !important; } -.attack-chain-action-btn.btn-primary:hover { - background: linear-gradient(135deg, #0052cc 0%, #0040a3 100%); - border-color: #0052cc; +.modal-header-actions .modal-close { + width: 32px; + height: 32px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, 0.04); + color: #64748b; + cursor: pointer; + transition: all 0.2s ease; + font-size: 20px; + line-height: 1; + user-select: none; + border: 1.5px solid transparent; +} + +.modal-header-actions .modal-close:hover { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border-color: rgba(239, 68, 68, 0.2); + transform: rotate(90deg); } .loading-spinner { @@ -6739,8 +6938,28 @@ header { align-items: center; justify-content: center; height: 100%; - color: var(--text-secondary); - font-size: 1rem; + color: #64748b; + font-size: 0.9375rem; + font-weight: 500; + position: relative; + z-index: 2; +} + +.attack-chain-container .loading-spinner::before { + content: ''; + display: inline-block; + width: 18px; + height: 18px; + margin-right: 10px; + border-radius: 50%; + border: 2.5px solid rgba(99, 102, 241, 0.2); + border-top-color: #6366f1; + animation: ac-spin 0.9s linear infinite; +} + +@keyframes ac-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } .empty-message { @@ -6748,8 +6967,11 @@ header { align-items: center; justify-content: center; height: 100%; - color: var(--text-secondary); - font-size: 1rem; + color: #64748b; + font-size: 0.9375rem; + font-weight: 500; + position: relative; + z-index: 2; } .error-message { @@ -6757,9 +6979,12 @@ header { align-items: center; justify-content: center; height: 100%; - color: var(--error-color); - font-size: 1rem; + color: #ef4444; + font-size: 0.9375rem; + font-weight: 500; padding: 20px; + position: relative; + z-index: 2; } /* ==================== 知识管理样式 ==================== */ diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 71ddf2a5..3e4e4613 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -546,6 +546,17 @@ "typeCustom": "Custom", "cmdParam": "Command parameter name", "cmdParamPlaceholder": "Leave empty for cmd; e.g. xxx for xxx=command", + "encoding": "Response encoding", + "encodingAuto": "Auto detect", + "encodingUtf8": "UTF-8", + "encodingGbk": "GBK (Simplified Chinese Windows)", + "encodingGb18030": "GB18030", + "encodingHint": "Switch to GBK or GB18030 if the Simplified Chinese Windows target shows garbled output.", + "os": "Target OS", + "osAuto": "Auto (infer from Shell type)", + "osLinux": "Linux / Unix", + "osWindows": "Windows", + "osHint": "Determines whether file manager / uploads use Linux or Windows commands. Choose Windows for PHP/JSP hosted on Windows.", "remark": "Remark", "remarkPlaceholder": "Friendly name for this connection", "deleteConfirm": "Delete this connection?", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 6d447c1a..3e687094 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -535,6 +535,17 @@ "typeCustom": "自定义", "cmdParam": "命令参数名", "cmdParamPlaceholder": "不填默认为 cmd,如填 xxx 则请求为 xxx=命令", + "encoding": "响应编码", + "encodingAuto": "自动检测", + "encodingUtf8": "UTF-8", + "encodingGbk": "GBK(中文 Windows)", + "encodingGb18030": "GB18030", + "encodingHint": "中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030", + "os": "目标系统", + "osAuto": "自动(按 Shell 类型推断)", + "osLinux": "Linux / Unix", + "osWindows": "Windows", + "osHint": "决定文件管理/上传使用 Linux 还是 Windows 命令;PHP/JSP 跑在 Windows 上请选 Windows", "remark": "备注", "remarkPlaceholder": "便于识别的备注名", "deleteConfirm": "确定要删除该连接吗?", diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 96c5f430..53dda90f 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -3082,6 +3082,39 @@ function updateActiveConversation() { // ==================== 攻击链可视化功能 ==================== +// 生成节点图标的 data URL(用于 Cytoscape background-image) +// 返回一个精美的渐变色方块 + 白色矢量图标 +function _acBuildNodeIconDataUrl(iconType, color, colorDark) { + let iconPath = ''; + if (iconType === 'target') { + iconPath = 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z'; + } else if (iconType === 'action') { + iconPath = 'M7 2v11h3v9l7-12h-4l4-8z'; + } else if (iconType === 'vulnerability') { + iconPath = 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-1 6h2v6h-2V7zm0 8h2v2h-2v-2z'; + } else { + iconPath = 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z'; + } + // 64x64 的图标方块(渐变 + 圆角 + 白色矢量图标) + const svg = ` + + + + + + + + +`; + // 使用 base64 编码(btoa 在浏览器中原生支持) + try { + return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); + } catch (e) { + // 兜底:URL 编码 + return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + } +} + let attackChainCytoscape = null; let currentAttackChainConversationId = null; // 按对话ID管理加载状态,实现不同对话之间的解耦 @@ -3303,78 +3336,119 @@ function renderAttackChain(chainData) { // 准备Cytoscape数据 const elements = []; - // 添加节点,并预计算文字颜色和边框颜色,同时为类型标签准备数据 + // 添加节点,并预计算样式信息(与导出保持一致的主题色) chainData.nodes.forEach(node => { const riskScore = node.risk_score || 0; const nodeType = node.type || ''; - - // 根据节点类型设置类型标签文本和标识符(使用更现代的设计) - let typeLabel = ''; - let typeBadge = ''; - let typeColor = ''; + const metadata = node.metadata || {}; + + // 统一的主题系统(与导出一致) + let typeLabel = '节点'; + let typeEn = 'NODE'; + let typeColor = '#334155'; // 主文字色 + let accentColor = '#94a3b8'; // 强调色(图标/边框) + let accentDark = '#475569'; // 深色版本 + let bgGradientStart = '#FFFFFF'; + let bgGradientEnd = '#F8FAFC'; + let iconType = 'default'; // 图标类型 + if (nodeType === 'target') { typeLabel = '目标'; - typeBadge = '○'; // 使用空心圆,更现代 - typeColor = '#1976d2'; // 蓝色 + typeEn = 'TARGET'; + typeColor = '#312E81'; + accentColor = '#4F46E5'; + accentDark = '#3730A3'; + bgGradientStart = '#FFFFFF'; + bgGradientEnd = '#F5F3FF'; + iconType = 'target'; } else if (nodeType === 'action') { typeLabel = '行动'; - typeBadge = '▷'; // 使用更简洁的三角形 - typeColor = '#f57c00'; // 橙色 + typeEn = 'ACTION'; + const findings = metadata.findings || []; + const hasFindings = Array.isArray(findings) && findings.length > 0; + const isFailedInsight = (metadata.status || '') === 'failed_insight'; + if (hasFindings && !isFailedInsight) { + typeColor = '#064E3B'; + accentColor = '#10B981'; + accentDark = '#047857'; + bgGradientStart = '#FFFFFF'; + bgGradientEnd = '#ECFDF5'; + } else { + typeColor = '#334155'; + accentColor = '#64748B'; + accentDark = '#475569'; + bgGradientStart = '#FFFFFF'; + bgGradientEnd = '#F8FAFC'; + } + iconType = 'action'; } else if (nodeType === 'vulnerability') { typeLabel = '漏洞'; - typeBadge = '◇'; // 使用空心菱形,更精致 - typeColor = '#d32f2f'; // 红色 - } else { - typeLabel = nodeType; - typeBadge = '•'; - typeColor = '#666'; + typeEn = 'VULNERABILITY'; + if (riskScore >= 80) { + typeColor = '#881337'; + accentColor = '#E11D48'; + accentDark = '#BE123C'; + bgGradientStart = '#FFFFFF'; + bgGradientEnd = '#FFF1F2'; + } else if (riskScore >= 60) { + typeColor = '#7C2D12'; + accentColor = '#EA580C'; + accentDark = '#C2410C'; + bgGradientStart = '#FFFFFF'; + bgGradientEnd = '#FFF7ED'; + } else if (riskScore >= 40) { + typeColor = '#713F12'; + accentColor = '#CA8A04'; + accentDark = '#A16207'; + bgGradientStart = '#FFFFFF'; + bgGradientEnd = '#FEFCE8'; + } else { + typeColor = '#134E4A'; + accentColor = '#0D9488'; + accentDark = '#0F766E'; + bgGradientStart = '#FFFFFF'; + bgGradientEnd = '#F0FDFA'; + } + iconType = 'vulnerability'; } - - // 根据风险分数计算文字颜色和边框颜色 - let textColor, borderColor, textOutlineWidth, textOutlineColor; - if (riskScore >= 80) { - // 红色背景:白色文字,白色边框 - textColor = '#fff'; - borderColor = '#fff'; - textOutlineWidth = 1; - textOutlineColor = '#333'; - } else if (riskScore >= 60) { - // 橙色背景:白色文字,白色边框 - textColor = '#fff'; - borderColor = '#fff'; - textOutlineWidth = 1; - textOutlineColor = '#333'; - } else if (riskScore >= 40) { - // 黄色背景:深色文字,深色边框 - textColor = '#333'; - borderColor = '#cc9900'; - textOutlineWidth = 2; - textOutlineColor = '#fff'; - } else { - // 绿色背景:深绿色文字,深色边框 - textColor = '#1a5a1a'; - borderColor = '#5a8a5a'; - textOutlineWidth = 2; - textOutlineColor = '#fff'; + + // 为每个节点生成图标 background-image(data URL) + const iconSvg = _acBuildNodeIconDataUrl(iconType, accentColor, accentDark); + + // 计算徽章文本(右上角) + let badgeText = ''; + if (nodeType === 'vulnerability' && riskScore > 0) { + const rl = riskScore >= 80 ? '严重' : riskScore >= 60 ? '高' : riskScore >= 40 ? '中' : '低'; + badgeText = rl + ' · ' + riskScore; + } else if (nodeType === 'action') { + const findings = metadata.findings || []; + if (Array.isArray(findings) && findings.length > 0 && metadata.status !== 'failed_insight') { + badgeText = '发现 ' + findings.length; + } else if (metadata.status === 'failed_insight') { + badgeText = '有线索'; + } + } else if (nodeType === 'target') { + badgeText = '主目标'; } - - // 保存节点数据,使用原始标签(样式中会添加类型标签) + elements.push({ data: { id: node.id, - label: node.label, // 原始标签 - originalLabel: node.label, // 保存原始标签用于搜索 + label: node.label, + originalLabel: node.label, type: nodeType, - typeLabel: typeLabel, // 保存类型标签文本 - typeBadge: typeBadge, // 保存类型标识符 - typeColor: typeColor, // 保存类型颜色 + typeLabel: typeLabel, + typeEn: typeEn, + typeColor: typeColor, + accentColor: accentColor, + accentDark: accentDark, + bgGradientStart: bgGradientStart, + bgGradientEnd: bgGradientEnd, + iconDataUrl: iconSvg, + badgeText: badgeText, riskScore: riskScore, toolExecutionId: node.tool_execution_id || '', - metadata: node.metadata || {}, - textColor: textColor, - borderColor: borderColor, - textOutlineWidth: textOutlineWidth, - textOutlineColor: textOutlineColor + metadata: metadata } }); }); @@ -3408,7 +3482,7 @@ function renderAttackChain(chainData) { } }); - // 初始化Cytoscape + // 初始化Cytoscape - 现代卡片式节点设计(图标 + 文字 + 徽章) attackChainCytoscape = cytoscape({ container: container, elements: elements, @@ -3416,167 +3490,133 @@ function renderAttackChain(chainData) { { selector: 'node', style: { - // 参考图二:现代化卡片设计,清晰的视觉层次 + // 节点 label:两行文字(类型英文 | 主标题) 'label': function(ele) { + const typeEn = ele.data('typeEn') || ''; const typeLabel = ele.data('typeLabel') || ''; const label = ele.data('label') || ''; - // 简洁的两行显示:类型标签 + 内容 - return typeLabel + '\n' + label; + const badgeText = ele.data('badgeText') || ''; + // 第一行:TYPE_EN · 类型(小字) + // 第二行:主标题(大字) + // 第三行:徽章文字(彩色提示) + let line1 = typeEn + ' · ' + typeLabel; + if (badgeText) line1 += ' [' + badgeText + ']'; + return line1 + '\n' + label; }, - // 合理的节点尺寸,参考图二 'width': function(ele) { const type = ele.data('type'); - if (type === 'target') return isComplexGraph ? 280 : 320; - if (type === 'vulnerability') return isComplexGraph ? 260 : 300; - return isComplexGraph ? 240 : 280; + if (type === 'target') return isComplexGraph ? 300 : 360; + if (type === 'vulnerability') return isComplexGraph ? 280 : 340; + return isComplexGraph ? 260 : 320; }, 'height': function(ele) { - const type = ele.data('type'); - if (type === 'target') return isComplexGraph ? 100 : 120; - if (type === 'vulnerability') return isComplexGraph ? 90 : 110; - return isComplexGraph ? 80 : 100; + return isComplexGraph ? 84 : 100; }, 'shape': 'round-rectangle', - // 现代化背景:白色卡片 + 左侧彩色条 - 'background-color': '#FFFFFF', + // 浅色渐变背景(白色到主题色极淡) + 'background-fill': 'linear-gradient', + 'background-gradient-direction': 'to-bottom-right', + 'background-gradient-stop-colors': function(ele) { + return (ele.data('bgGradientStart') || '#FFFFFF') + ' ' + + (ele.data('bgGradientEnd') || '#F8FAFC'); + }, + 'background-gradient-stop-positions': '0 100', 'background-opacity': 1, - // 左侧彩色条效果(通过边框实现) - 'border-width': function(ele) { - const type = ele.data('type'); - return 0; // 无边框,使用背景色块 + // 左侧类型图标(SVG dataURL 作为背景图) + 'background-image': function(ele) { + return ele.data('iconDataUrl') || 'none'; }, - 'border-color': 'transparent', - // 文字样式:清晰易读 - 'color': '#2C3E50', // 深蓝灰色,专业感 + 'background-image-containment': 'inside', + 'background-fit': 'none', + 'background-image-opacity': 1, + 'background-width': '36px', + 'background-height': '36px', + 'background-position-x': '18px', + 'background-position-y': '50%', + 'background-offset-y': '0', + 'background-clip': 'node', + 'bounds-expansion': 0, + // 边框:主题色柔和 + 'border-width': 1.5, + 'border-color': function(ele) { + return ele.data('accentColor') || '#94a3b8'; + }, + 'border-opacity': 0.5, + // 文字样式 + 'color': '#0f172a', 'font-size': function(ele) { - const type = ele.data('type'); - if (type === 'target') return isComplexGraph ? '14px' : '16px'; - if (type === 'vulnerability') return isComplexGraph ? '13px' : '15px'; - return isComplexGraph ? '13px' : '15px'; + return isComplexGraph ? '13px' : '14px'; }, - 'font-weight': '600', // 中等加粗 - 'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Microsoft YaHei", sans-serif', + 'font-weight': 700, + 'font-family': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif', + // 文字左对齐、预留左侧图标空间 'text-valign': 'center', 'text-halign': 'center', + 'text-justification': 'left', 'text-wrap': 'wrap', 'text-max-width': function(ele) { const type = ele.data('type'); - if (type === 'target') return isComplexGraph ? '240px' : '280px'; - if (type === 'vulnerability') return isComplexGraph ? '220px' : '260px'; - return isComplexGraph ? '200px' : '240px'; + const w = (type === 'target') ? (isComplexGraph ? 300 : 360) + : (type === 'vulnerability') ? (isComplexGraph ? 280 : 340) + : (isComplexGraph ? 260 : 320); + return (w - 80) + 'px'; }, 'text-overflow-wrap': 'anywhere', - 'text-margin-y': 4, - 'padding': '12px 16px', // 合理的内边距 - 'line-height': 1.5, - 'text-outline-width': 0 + 'text-margin-x': 28, + 'text-margin-y': 0, + 'padding': '12px', + 'line-height': 1.4, + 'text-outline-width': 0, + // 柔和阴影(overlay 模拟) + 'overlay-color': '#0f172a', + 'overlay-opacity': 0, + 'overlay-padding': 0, + 'transition-property': 'overlay-opacity, border-width, border-color', + 'transition-duration': '160ms' } }, { - // 目标节点:蓝色主题 + // 目标节点:边框略粗 selector: 'node[type = "target"]', style: { - 'background-color': '#E3F2FD', - 'color': '#1565C0', - 'border-width': 3, - 'border-color': '#2196F3', - 'border-style': 'solid' + 'border-width': 2 } }, { - // 行动节点:根据状态显示不同颜色 - selector: 'node[type = "action"]', - style: { - 'background-color': function(ele) { - const metadata = ele.data('metadata') || {}; - const findings = metadata.findings || []; - const status = metadata.status || ''; - const hasFindings = Array.isArray(findings) && findings.length > 0; - const isFailedInsight = status === 'failed_insight'; - - if (hasFindings && !isFailedInsight) { - return '#E8F5E9'; // 浅绿色背景 - } else { - return '#F5F5F5'; // 浅灰色背景 - } - }, - 'color': '#424242', - 'border-width': 2, - 'border-color': function(ele) { - const metadata = ele.data('metadata') || {}; - const findings = metadata.findings || []; - const status = metadata.status || ''; - const hasFindings = Array.isArray(findings) && findings.length > 0; - const isFailedInsight = status === 'failed_insight'; - - if (hasFindings && !isFailedInsight) { - return '#4CAF50'; // 绿色边框 - } else { - return '#9E9E9E'; // 灰色边框 - } - }, - 'border-style': 'solid' - } - }, - { - // 漏洞节点:根据风险等级显示颜色 + // 漏洞节点:边框略粗 selector: 'node[type = "vulnerability"]', style: { - 'background-color': function(ele) { - const riskScore = ele.data('riskScore') || 0; - if (riskScore >= 80) return '#FFEBEE'; - if (riskScore >= 60) return '#FFF3E0'; - if (riskScore >= 40) return '#FFFDE7'; - return '#E8F5E9'; - }, - 'color': function(ele) { - const riskScore = ele.data('riskScore') || 0; - if (riskScore >= 80) return '#C62828'; - if (riskScore >= 60) return '#E65100'; - if (riskScore >= 40) return '#F57C00'; - return '#2E7D32'; - }, - 'border-width': 3, - 'border-color': function(ele) { - const riskScore = ele.data('riskScore') || 0; - if (riskScore >= 80) return '#F44336'; - if (riskScore >= 60) return '#FF9800'; - if (riskScore >= 40) return '#FFC107'; - return '#4CAF50'; - }, - 'border-style': 'solid' + 'border-width': 2 } }, { selector: 'edge', style: { - // 参考图二:简洁清晰的连接线 'width': function(ele) { const type = ele.data('type'); - if (type === 'discovers') return 2.5; // 发现漏洞的边稍粗 - if (type === 'enables') return 2.5; // 使能关系稍粗 - return 2; // 普通边 + if (type === 'discovers') return 2.6; + if (type === 'enables') return 2.8; + return 2; }, 'line-color': function(ele) { const type = ele.data('type'); - if (type === 'discovers') return '#42A5F5'; // 蓝色 - if (type === 'targets') return '#42A5F5'; // 蓝色 - if (type === 'enables') return '#EF5350'; // 红色 - if (type === 'leads_to') return '#90A4AE'; // 灰蓝色 - return '#B0BEC5'; + if (type === 'discovers' || type === 'targets') return '#4F46E5'; + if (type === 'enables') return '#E11D48'; + if (type === 'leads_to') return '#64748B'; + return '#cbd5e1'; }, 'target-arrow-color': function(ele) { const type = ele.data('type'); - if (type === 'discovers') return '#42A5F5'; - if (type === 'targets') return '#42A5F5'; - if (type === 'enables') return '#EF5350'; - if (type === 'leads_to') return '#90A4AE'; - return '#B0BEC5'; + if (type === 'discovers' || type === 'targets') return '#4F46E5'; + if (type === 'enables') return '#E11D48'; + if (type === 'leads_to') return '#64748B'; + return '#cbd5e1'; }, - 'target-arrow-shape': 'triangle', - 'arrow-scale': 1.2, // 适中的箭头大小 - 'curve-style': 'straight', - 'opacity': 0.7, // 适中的不透明度 + 'target-arrow-shape': 'triangle-backcurve', + 'arrow-scale': 1.35, + 'curve-style': 'bezier', + 'control-point-step-size': 60, + 'opacity': 0.88, 'line-style': function(ele) { const type = ele.data('type'); if (type === 'targets') return 'dashed'; @@ -3584,26 +3624,31 @@ function renderAttackChain(chainData) { }, 'line-dash-pattern': function(ele) { const type = ele.data('type'); - if (type === 'targets') return [8, 4]; + if (type === 'targets') return [10, 5]; return []; - } + }, + 'transition-property': 'opacity, width, line-color', + 'transition-duration': '160ms' } }, { selector: 'node:selected', style: { - 'border-width': 5, - 'border-color': '#0066ff', + 'border-width': 3.5, + 'border-color': '#4F46E5', 'z-index': 999, 'opacity': 1, - 'overlay-opacity': 0.1, - 'overlay-color': '#0066ff' + 'overlay-opacity': 0.06, + 'overlay-color': '#4F46E5', + 'overlay-padding': 8 } } ], userPanningEnabled: true, userZoomingEnabled: true, - boxSelectionEnabled: true + boxSelectionEnabled: true, + minZoom: 0.2, + maxZoom: 3 }); // 使用ELK布局(高质量DAG布局,减少边交叉) @@ -3628,38 +3673,47 @@ function renderAttackChain(chainData) { if (elkInstance) { try { - // 构建ELK图结构 + // === 布局参数(始终使用 DOWN 纵向布局)=== + const isSmallGraph = chainData.nodes.length <= 8 && validEdges.length <= 12; + // 同层节点间距(横向分散) + const nodeGap = isComplexGraph ? 45 : isSmallGraph ? 80 : 60; + // 层间距(纵向:给连线足够发挥空间,同时避免图太高) + const layerGap = isComplexGraph ? 70 : isSmallGraph ? 130 : 95; + + // 构建 ELK 图结构 - 节点尺寸与 Cytoscape 样式保持一致 const elkGraph = { id: 'root', layoutOptions: { 'elk.algorithm': 'layered', 'elk.direction': 'DOWN', - 'elk.spacing.nodeNode': String(isComplexGraph ? 100 : 120), // 合理的节点间距 - 'elk.spacing.edgeNode': '50', // 合理的边到节点间距 - 'elk.spacing.edgeEdge': '25', // 合理的边间距 - 'elk.layered.spacing.nodeNodeBetweenLayers': String(isComplexGraph ? 150 : 180), // 合理的层级间距 - 'elk.layered.nodePlacement.strategy': 'SIMPLE', // 使用简单策略,让布局更分散 - 'elk.layered.crossingMinimization.strategy': 'INTERACTIVE', // 交互式交叉最小化 - 'elk.layered.thoroughness': '10', // 最高优化程度 - 'elk.layered.spacing.edgeNodeBetweenLayers': '50', + 'elk.padding': '[top=30,left=50,bottom=30,right=50]', + 'elk.spacing.nodeNode': String(nodeGap), + 'elk.spacing.edgeNode': '20', + 'elk.spacing.edgeEdge': '12', + 'elk.spacing.componentComponent': '50', + 'elk.layered.spacing.nodeNodeBetweenLayers': String(layerGap), + 'elk.layered.spacing.edgeNodeBetweenLayers': '20', + 'elk.layered.spacing.edgeEdgeBetweenLayers': '12', 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', + 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', + 'elk.layered.nodePlacement.bk.edgeStraightening': 'IMPROVE_STRAIGHTNESS', 'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP', - 'elk.layered.crossingMinimization.forceNodeModelOrder': 'true', + 'elk.layered.crossingMinimization.semiInteractive': 'false', + 'elk.layered.thoroughness': String(isComplexGraph ? 10 : 15), 'elk.layered.cycleBreaking.strategy': 'GREEDY', - 'elk.layered.thoroughness': '7', - 'elk.padding': '[top=60,left=100,bottom=60,right=100]', // 更大的左右边距,让图更分散 - 'elk.spacing.componentComponent': String(isComplexGraph ? 100 : 120) // 组件间距 + 'elk.layered.compaction.connectedComponents': 'true', + 'elk.layered.compaction.postCompaction.strategy': 'LEFT_RIGHT_CONSTRAINT_LOCKING', + 'elk.layered.unnecessaryBendpoints': 'true', + 'elk.layered.mergeEdges': 'false' }, children: chainData.nodes.map(node => { const type = node.type || ''; return { id: node.id, - width: type === 'target' ? (isComplexGraph ? 280 : 320) : - type === 'vulnerability' ? (isComplexGraph ? 260 : 300) : - (isComplexGraph ? 240 : 280), - height: type === 'target' ? (isComplexGraph ? 100 : 120) : - type === 'vulnerability' ? (isComplexGraph ? 90 : 110) : - (isComplexGraph ? 80 : 100) + width: type === 'target' ? (isComplexGraph ? 300 : 360) : + type === 'vulnerability' ? (isComplexGraph ? 280 : 340) : + (isComplexGraph ? 260 : 320), + height: isComplexGraph ? 84 : 100 }; }), edges: validEdges.map(edge => ({ @@ -3724,77 +3778,54 @@ function renderAttackChain(chainData) { layout.run(); } - // 居中攻击链的函数 + // 居中攻击链的函数:始终让所有节点完整可见 function centerAttackChain() { try { if (!attackChainCytoscape) { return; } - const container = attackChainCytoscape.container(); - if (!container) { - return; - } - + if (!container) return; const containerWidth = container.offsetWidth; const containerHeight = container.offsetHeight; - if (containerWidth === 0 || containerHeight === 0) { - // 如果容器尺寸为0,延迟重试 setTimeout(centerAttackChain, 100); return; } - - // 居中显示图,同时保持合理的缩放 - const padding = 80; // 边距 + + // 使用较大 padding 让节点不贴边,视觉上更舒适 + // 核心原则:完全依赖 fit 的结果来保证全局可见,不强制最小缩放 + const padding = 60; attackChainCytoscape.fit(undefined, padding); - - // 等待fit完成后再调整 + + // 只在极端情况下微调:小图(2-3 节点)fit 后缩放过大时适当降低 setTimeout(() => { - const extent = attackChainCytoscape.extent(); - if (!extent || typeof extent.x1 === 'undefined' || typeof extent.x2 === 'undefined' || - typeof extent.y1 === 'undefined' || typeof extent.y2 === 'undefined') { - return; - } - - const graphWidth = extent.x2 - extent.x1; - const graphHeight = extent.y2 - extent.y1; + if (!attackChainCytoscape) return; const currentZoom = attackChainCytoscape.zoom(); - - // 如果图太小,适当放大 - const availableWidth = containerWidth - padding * 2; - const availableHeight = containerHeight - padding * 2; - const widthScale = graphWidth > 0 ? availableWidth / (graphWidth * currentZoom) : 1; - const heightScale = graphHeight > 0 ? availableHeight / (graphHeight * currentZoom) : 1; - const scale = Math.min(widthScale, heightScale); - - // 只在合理范围内调整缩放(0.8-1.3倍) - if (scale > 1 && scale < 1.3) { - attackChainCytoscape.zoom(currentZoom * scale); - } else if (scale < 0.8) { - attackChainCytoscape.zoom(currentZoom * 0.8); + // 上限:避免节点占满屏幕看起来过大 + const MAX_INITIAL_ZOOM = 1.25; + // 下限:避免极小图看不清(极小图通常节点很少) + const MIN_READABLE_ZOOM = 0.25; + + let targetZoom = currentZoom; + if (currentZoom > MAX_INITIAL_ZOOM) { + targetZoom = MAX_INITIAL_ZOOM; + } else if (currentZoom < MIN_READABLE_ZOOM) { + // 如果 fit 后缩放低于 0.25,说明图超大;保持当前结果,让用户可拖动查看 + targetZoom = MIN_READABLE_ZOOM; } - - // 确保图居中 - const graphCenterX = (extent.x1 + extent.x2) / 2; - const graphCenterY = (extent.y1 + extent.y2) / 2; - const zoom = attackChainCytoscape.zoom(); - const pan = attackChainCytoscape.pan(); - - const graphCenterViewX = graphCenterX * zoom + pan.x; - const graphCenterViewY = graphCenterY * zoom + pan.y; - - const desiredViewX = containerWidth / 2; - const desiredViewY = containerHeight / 2; - - const deltaX = desiredViewX - graphCenterViewX; - const deltaY = desiredViewY - graphCenterViewY; - - attackChainCytoscape.pan({ - x: pan.x + deltaX, - y: pan.y + deltaY - }); - }, 100); + + if (Math.abs(targetZoom - currentZoom) > 0.01) { + const extent = attackChainCytoscape.extent(); + const cx = (extent.x1 + extent.x2) / 2; + const cy = (extent.y1 + extent.y2) / 2; + attackChainCytoscape.zoom({ + level: targetZoom, + position: { x: cx, y: cy } + }); + } + attackChainCytoscape.center(); + }, 60); } catch (error) { console.warn('居中图表时出错:', error); } @@ -3805,26 +3836,47 @@ function renderAttackChain(chainData) { const node = evt.target; showNodeDetails(node.data()); }); - - // 添加悬停效果(使用事件监听器替代CSS选择器) + + // 点击空白处关闭详情 + attackChainCytoscape.on('tap', function(evt) { + if (evt.target === attackChainCytoscape) { + attackChainCytoscape.elements().unselect(); + } + }); + + // 添加悬停效果:增强边框 + 柔光叠加 + 淡化不相关连线 attackChainCytoscape.on('mouseover', 'node', function(evt) { const node = evt.target; - node.style('border-width', 5); - node.style('z-index', 998); - node.style('overlay-opacity', 0.05); - node.style('overlay-color', '#333333'); + const accent = node.data('accentColor') || '#4F46E5'; + node.style({ + 'border-width': 3, + 'border-color': accent, + 'border-opacity': 1, + 'overlay-color': accent, + 'overlay-opacity': 0.08, + 'overlay-padding': 10, + 'z-index': 998 + }); + const connected = node.connectedEdges(); + attackChainCytoscape.edges().not(connected).style('opacity', 0.2); + connected.style({ 'opacity': 1, 'width': 3.5 }); }); - + attackChainCytoscape.on('mouseout', 'node', function(evt) { const node = evt.target; const type = node.data('type'); - // 恢复默认边框宽度 - const defaultBorderWidth = type === 'target' ? 5 : 4; - node.style('border-width', defaultBorderWidth); - node.style('z-index', 'auto'); - node.style('overlay-opacity', 0); + const defaultBorderWidth = (type === 'target' || type === 'vulnerability') ? 2 : 1.5; + node.style({ + 'border-width': defaultBorderWidth, + 'border-color': node.data('accentColor') || '#94a3b8', + 'border-opacity': 0.5, + 'overlay-opacity': 0, + 'overlay-padding': 0, + 'z-index': 0 + }); + attackChainCytoscape.edges().style({ 'opacity': 0.88, 'width': '' }); }); - + // 保存原始数据用于过滤 window.attackChainOriginalData = chainData; } @@ -4041,11 +4093,14 @@ function showNodeDetails(nodeData) { if (!detailsPanel || !detailsContent) { return; } - + + // 给 sidebar 标记详情激活态,CSS 会隐藏图例让详情独占空间 + const sidebar = document.querySelector('.attack-chain-sidebar'); + if (sidebar) sidebar.classList.add('details-active'); + // 使用 requestAnimationFrame 优化显示动画 requestAnimationFrame(() => { detailsPanel.style.display = 'flex'; - // 在下一帧设置透明度,确保显示动画流畅 requestAnimationFrame(() => { detailsPanel.style.opacity = '1'; }); @@ -4157,37 +4212,17 @@ function showNodeDetails(nodeData) { `; } - // 先重置滚动位置,避免内容更新时的滚动计算 + // 详情占满 sidebar 后,内容区滚动由自身处理,重置到顶部 if (detailsContent) { detailsContent.scrollTop = 0; } - - // 使用 requestAnimationFrame 优化 DOM 更新和滚动 + requestAnimationFrame(() => { - // 更新内容 detailsContent.innerHTML = html; - - // 在下一帧执行滚动,避免与 DOM 更新冲突 requestAnimationFrame(() => { - // 重置详情内容区域的滚动位置 if (detailsContent) { detailsContent.scrollTop = 0; } - - // 重置侧边栏的滚动位置,确保详情区域可见 - const sidebar = document.querySelector('.attack-chain-sidebar-content'); - if (sidebar) { - // 找到详情面板的位置 - const detailsPanel = document.getElementById('attack-chain-details'); - if (detailsPanel && detailsPanel.offsetParent !== null) { - // 使用 getBoundingClientRect 获取位置,性能更好 - const detailsRect = detailsPanel.getBoundingClientRect(); - const sidebarRect = sidebar.getBoundingClientRect(); - const scrollTop = sidebar.scrollTop; - const relativeTop = detailsRect.top - sidebarRect.top + scrollTop; - sidebar.scrollTop = relativeTop - 20; // 留一点边距 - } - } }); }); } @@ -4245,19 +4280,20 @@ document.addEventListener('languagechange', function () { // 关闭节点详情 function closeNodeDetails() { const detailsPanel = document.getElementById('attack-chain-details'); + const sidebar = document.querySelector('.attack-chain-sidebar'); + if (detailsPanel) { - // 添加淡出动画 detailsPanel.style.opacity = '0'; - detailsPanel.style.maxHeight = detailsPanel.scrollHeight + 'px'; - setTimeout(() => { detailsPanel.style.display = 'none'; - detailsPanel.style.maxHeight = ''; detailsPanel.style.opacity = ''; - }, 300); + // 移除详情激活态,图例恢复显示 + if (sidebar) sidebar.classList.remove('details-active'); + }, 220); + } else if (sidebar) { + sidebar.classList.remove('details-active'); } - - // 取消选中节点 + if (attackChainCytoscape) { attackChainCytoscape.elements().unselect(); } @@ -4404,375 +4440,811 @@ async function regenerateAttackChain() { } } -// 导出攻击链 +// ==================== 攻击链导出(精美版) ==================== + +// XML/HTML 转义 +function _acEscapeXml(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// 按字符宽度做软换行(支持中英文混排),返回字符串数组 +function _acWrapLabel(label, maxChars, maxLines) { + if (!label) return ['']; + const text = String(label).replace(/\s+/g, ' ').trim(); + if (!text) return ['']; + // 以"字符宽度"估算:中文算2,其他算1 + const width = (ch) => (/[\u4e00-\u9fa5\uff00-\uffef]/.test(ch) ? 2 : 1); + const maxW = maxChars * 1.8; // 以单位宽度衡量 + + const lines = []; + let buf = ''; + let bufW = 0; + let lastSpaceIdx = -1; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + const w = width(ch); + if (ch === ' ') lastSpaceIdx = buf.length; + if (bufW + w > maxW) { + // 在空格处换行(英文更自然) + let cut = buf; + let rest = ''; + if (lastSpaceIdx > 0 && lastSpaceIdx >= buf.length - 10) { + cut = buf.substring(0, lastSpaceIdx); + rest = buf.substring(lastSpaceIdx + 1); + } + lines.push(cut); + if (lines.length >= maxLines) { + // 在最后加省略号 + const last = lines[lines.length - 1]; + lines[lines.length - 1] = _acTruncateToWidth(last, maxW - 2) + '…'; + return lines; + } + buf = rest + ch; + bufW = 0; + for (let j = 0; j < buf.length; j++) bufW += width(buf[j]); + lastSpaceIdx = -1; + } else { + buf += ch; + bufW += w; + } + } + if (buf) lines.push(buf); + if (lines.length > maxLines) { + const kept = lines.slice(0, maxLines); + kept[kept.length - 1] = _acTruncateToWidth(kept[kept.length - 1], maxW - 2) + '…'; + return kept; + } + return lines; +} + +function _acTruncateToWidth(str, maxW) { + const width = (ch) => (/[\u4e00-\u9fa5\uff00-\uffef]/.test(ch) ? 2 : 1); + let w = 0; + let out = ''; + for (let i = 0; i < str.length; i++) { + w += width(str[i]); + if (w > maxW) break; + out += str[i]; + } + return out; +} + +// 计算颜色对应的深色主色(辅助 accent 深色) +function _acDarken(hex, amount) { + try { + const h = hex.replace('#', ''); + const r = parseInt(h.substring(0, 2), 16); + const g = parseInt(h.substring(2, 4), 16); + const b = parseInt(h.substring(4, 6), 16); + const f = (c) => Math.max(0, Math.min(255, Math.round(c * (1 - amount)))); + return '#' + [f(r), f(g), f(b)].map(x => x.toString(16).padStart(2, '0')).join(''); + } catch (e) { + return hex; + } +} + +// 从当前 Cytoscape 实例收集导出所需的节点/边信息 +function _acCollectExportData() { + if (!attackChainCytoscape) return null; + const nodes = []; + attackChainCytoscape.nodes().forEach(n => { + // 过滤隐藏节点 + if (n.style('display') === 'none') return; + const pos = n.position(); + // 读取 Cytoscape 中实际渲染的节点尺寸,保证导出与看板一致 + let w = n.outerWidth ? n.outerWidth() : n.width(); + let h = n.outerHeight ? n.outerHeight() : n.height(); + // 兜底 + if (!w || !isFinite(w) || w < 40) w = 280; + if (!h || !isFinite(h) || h < 30) h = 96; + nodes.push({ + id: n.id(), + x: pos.x, + y: pos.y, + w: w, + h: h, + type: n.data('type') || '', + typeLabel: n.data('typeLabel') || '', + typeBadge: n.data('typeBadge') || '•', + typeColor: n.data('typeColor') || '#334155', + accentColor: n.data('accentColor') || '#94a3b8', + bgGradientStart: n.data('bgGradientStart') || '#FFFFFF', + bgGradientEnd: n.data('bgGradientEnd') || '#F8FAFC', + riskScore: n.data('riskScore') || 0, + label: n.data('originalLabel') || n.data('label') || n.id(), + metadata: n.data('metadata') || {} + }); + }); + + const edges = []; + attackChainCytoscape.edges().forEach(e => { + if (e.style('display') === 'none') return; + const info = getEdgeNodes(e); + if (!info.valid) return; + const s = info.source.position(); + const t = info.target.position(); + edges.push({ + id: e.id(), + source: info.source.id(), + target: info.target.id(), + sx: s.x, sy: s.y, + tx: t.x, ty: t.y, + type: e.data('type') || 'leads_to' + }); + }); + + return { nodes, edges }; +} + +// 节点类型图标(SVG path)—— 真正的矢量图标 +function _acGetNodeIconPath(type) { + // 24×24 视图下的 path(会被缩放到 iconSize) + if (type === 'target') { + // 靶子(同心圆 + 十字准星) + return 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z'; + } + if (type === 'action') { + // 闪电(行动) + return 'M7 2v11h3v9l7-12h-4l4-8z'; + } + if (type === 'vulnerability') { + // 盾牌警告 + return 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-1 6h2v6h-2V7zm0 8h2v2h-2v-2z'; + } + // 默认点 + return 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z'; +} + +// 获取节点风险等级标签(漏洞节点用) +function _acGetRiskLabel(score) { + if (score >= 80) return '严重'; + if (score >= 60) return '高'; + if (score >= 40) return '中'; + if (score > 0) return '低'; + return ''; +} + +// 生成精美 SVG 字符串(高端商业报告风格) +function _acBuildSvgString() { + const data = _acCollectExportData(); + if (!data || data.nodes.length === 0) throw new Error('没有可导出的数据'); + + const { nodes, edges } = data; + + // --- 关键:重新统一节点尺寸为大卡片设计(SVG 中使用自己的规格) --- + // SVG 导出时采用更大的卡片,让信息层次清晰 + nodes.forEach(n => { + // 根据当前在 Cytoscape 中的位置,重新分配 SVG 版本的宽高 + // 统一使用大卡片以便展示完整信息 + n.w = 380; + n.h = 140; + }); + + // 计算图的包围盒 + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + nodes.forEach(n => { + minX = Math.min(minX, n.x - n.w / 2); + minY = Math.min(minY, n.y - n.h / 2); + maxX = Math.max(maxX, n.x + n.w / 2); + maxY = Math.max(maxY, n.y + n.h / 2); + }); + + // ==================== 版面布局参数 ==================== + const GRAPH_PAD = 100; // 图区域内部留白 + const HEADER_H = 128; // 顶部标题栏(加大) + const FOOTER_H = 56; // 底部信息栏 + const LEGEND_W = 320; // 右侧图例面板 + const OUTER_PAD = 32; // 最外层留白 + + const rawGraphW = (maxX - minX) + GRAPH_PAD * 2; + const rawGraphH = (maxY - minY) + GRAPH_PAD * 2; + + const minGraphW = 900; + const minGraphH = 620; + const graphW = Math.max(rawGraphW, minGraphW); + const graphH = Math.max(rawGraphH, minGraphH); + + const contentW = graphW + LEGEND_W + 36; + const contentH = graphH + HEADER_H + FOOTER_H + 24; + + const totalW = contentW + OUTER_PAD * 2; + const totalH = contentH + OUTER_PAD * 2; + + // 图区域在卡片坐标系的位置 + const graphAreaX = OUTER_PAD + 20; + const graphAreaY = OUTER_PAD + HEADER_H; + const graphAreaW = graphW - 4; + const graphAreaH = contentH - HEADER_H - FOOTER_H; + + // 节点坐标映射 + const graphCenterOffsetX = (graphAreaW - rawGraphW) / 2; + const graphCenterOffsetY = (graphAreaH - rawGraphH) / 2; + const graphOriginX = graphAreaX + GRAPH_PAD + graphCenterOffsetX - minX; + const graphOriginY = graphAreaY + GRAPH_PAD + graphCenterOffsetY - minY; + + // 图例区坐标 + const legendX = graphAreaX + graphW + 16; + + // 统计信息 + const nodeCount = nodes.length; + const edgeCount = edges.length; + const vulnNodes = nodes.filter(n => n.type === 'vulnerability'); + const actionNodes = nodes.filter(n => n.type === 'action'); + const targetNodes = nodes.filter(n => n.type === 'target'); + const criticalCount = vulnNodes.filter(n => n.riskScore >= 80).length; + const highCount = vulnNodes.filter(n => n.riskScore >= 60 && n.riskScore < 80).length; + const medCount = vulnNodes.filter(n => n.riskScore >= 40 && n.riskScore < 60).length; + const lowCount = vulnNodes.filter(n => n.riskScore > 0 && n.riskScore < 40).length; + + const timestamp = new Date(); + const ts = timestamp.getFullYear() + '-' + + String(timestamp.getMonth() + 1).padStart(2, '0') + '-' + + String(timestamp.getDate()).padStart(2, '0') + ' ' + + String(timestamp.getHours()).padStart(2, '0') + ':' + + String(timestamp.getMinutes()).padStart(2, '0'); + + // 类型主题色(高级感配色) + const typeTheme = { + 'target': { primary: '#4F46E5', light: '#EEF2FF', dark: '#3730A3', text: '#312E81', label: '目标' }, + 'action-success': { primary: '#10B981', light: '#ECFDF5', dark: '#047857', text: '#064E3B', label: '行动' }, + 'action-neutral': { primary: '#64748B', light: '#F8FAFC', dark: '#475569', text: '#334155', label: '行动' }, + 'vuln-critical': { primary: '#E11D48', light: '#FFF1F2', dark: '#BE123C', text: '#881337', label: '漏洞' }, + 'vuln-high': { primary: '#EA580C', light: '#FFF7ED', dark: '#C2410C', text: '#7C2D12', label: '漏洞' }, + 'vuln-med': { primary: '#CA8A04', light: '#FEFCE8', dark: '#A16207', text: '#713F12', label: '漏洞' }, + 'vuln-low': { primary: '#0D9488', light: '#F0FDFA', dark: '#0F766E', text: '#134E4A', label: '漏洞' } + }; + + function themeFor(n) { + if (n.type === 'target') return typeTheme['target']; + if (n.type === 'action') { + const m = n.metadata || {}; + const findings = m.findings || []; + const hasFindings = Array.isArray(findings) && findings.length > 0; + const isFailed = m.status === 'failed_insight'; + return (hasFindings && !isFailed) ? typeTheme['action-success'] : typeTheme['action-neutral']; + } + if (n.type === 'vulnerability') { + const s = n.riskScore || 0; + if (s >= 80) return typeTheme['vuln-critical']; + if (s >= 60) return typeTheme['vuln-high']; + if (s >= 40) return typeTheme['vuln-med']; + return typeTheme['vuln-low']; + } + return typeTheme['action-neutral']; + } + + // 边的起止主题 + const nodesMap = new Map(nodes.map(n => [n.id, n])); + + // ==================== 开始组装 SVG ==================== + const parts = []; + parts.push(``); + parts.push(``); + + // ==================== defs ==================== + parts.push(``); + + // 根背景渐变:极淡暖灰 + parts.push(` + + + `); + + // 角落光晕 + parts.push(` + + + `); + parts.push(` + + + `); + parts.push(` + + + `); + + // 品牌渐变(标题用) + parts.push(` + + + + `); + + // 网格点阵(非常淡) + parts.push(` + + `); + + // 节点卡片阴影(多层阴影,更有层次) + parts.push(` + + + `); + + // 图标徽章阴影 + parts.push(` + + `); + + // 风险徽章阴影 + parts.push(` + + `); + + // 为每个节点定义图标渐变(大图标用) + Object.keys(typeTheme).forEach(key => { + const t = typeTheme[key]; + parts.push(` + + + `); + }); + + // 边使用渐变(源 -> 目标) + edges.forEach((e, idx) => { + const sNode = nodesMap.get(e.source); + const tNode = nodesMap.get(e.target); + if (!sNode || !tNode) return; + const sTheme = themeFor(sNode); + const tTheme = themeFor(tNode); + parts.push(` + + + `); + }); + + // 为每种主题色定义箭头标记 + Object.keys(typeTheme).forEach(key => { + const t = typeTheme[key]; + parts.push(` + + `); + }); + + parts.push(``); + + // ==================== 背景 ==================== + parts.push(``); + // 角落光晕 + parts.push(``); + parts.push(``); + parts.push(``); + + // ==================== 主卡片 ==================== + parts.push(``); + + // ==================== 顶部标题栏 ==================== + const tX = OUTER_PAD + 40; + const tY = OUTER_PAD + 28; + + // 左侧 Logo 色块(大,渐变,有设计感) + parts.push(``); + parts.push(``); + // Logo 图标(六边形 + 闪电) + parts.push(` + + + `); + parts.push(``); + + // 主标题(超大、粗体) + parts.push(`攻击链可视化报告`); + + // 副标题(小字、次要色) + parts.push(`Attack Chain Analysis · ${_acEscapeXml(ts)}`); + + // 右上角:关键统计胶囊(3 个) + const kpiY = OUTER_PAD + 28; + const kpiH = 48; + const kpiGap = 12; + const kpiW = 110; + const kpiItems = [ + { label: '节点', value: nodeCount, color: '#4F46E5' }, + { label: '连线', value: edgeCount, color: '#06B6D4' }, + { label: '严重漏洞', value: criticalCount, color: criticalCount > 0 ? '#E11D48' : '#94A3B8' } + ]; + let kpiXStart = OUTER_PAD + contentW - 40 - (kpiW * kpiItems.length + kpiGap * (kpiItems.length - 1)); + kpiItems.forEach((kpi, i) => { + const kx = kpiXStart + i * (kpiW + kpiGap); + // 卡片背景 + parts.push(``); + // 左侧细条 + parts.push(``); + // 数值(大字) + parts.push(`${kpi.value}`); + // 标签(小字) + parts.push(`${_acEscapeXml(kpi.label)}`); + }); + + // 标题分隔线(渐变淡化) + parts.push(``); + + // ==================== 图区域 ==================== + parts.push(``); + parts.push(``); + + // ==================== 开始绘制图形 ==================== + parts.push(``); + + // ---- 边(渐变、柔和曲线) ---- + edges.forEach((e, idx) => { + const sNode = nodesMap.get(e.source); + const tNode = nodesMap.get(e.target); + if (!sNode || !tNode) return; + const tTheme = themeFor(tNode); + + const dx = e.tx - e.sx; + const dy = e.ty - e.sy; + const mag = Math.sqrt(dx * dx + dy * dy) || 1; + const offset = Math.min(80, mag * 0.25); + const nx = -dy / mag; + const ny = dx / mag; + const cx = (e.sx + e.tx) / 2 + nx * offset; + const cy = (e.sy + e.ty) / 2 + ny * offset; + const shrink = 22; + const ex = e.tx - (dx / mag) * shrink; + const ey = e.ty - (dy / mag) * shrink; + + const strokeWidth = (e.type === 'discovers' || e.type === 'enables') ? 2.4 : 2; + const strokeDash = e.type === 'targets' ? 'stroke-dasharray="10,5"' : ''; + // 目标箭头 key(根据目标节点的主题) + const targetThemeKey = Object.keys(typeTheme).find(k => typeTheme[k] === tTheme) || 'action-neutral'; + + // 先画一个轻微的 halo(背景光晕) + parts.push(``); + // 主线 + parts.push(``); + }); + + // ---- 节点(大卡片设计) ---- + nodes.forEach((n, i) => { + const theme = themeFor(n); + const themeKey = Object.keys(typeTheme).find(k => typeTheme[k] === theme) || 'action-neutral'; + + const x = n.x - n.w / 2; + const y = n.y - n.h / 2; + const r = 18; // 圆角 + + // ========== 卡片主体 ========== + // 柔和阴影 + 纯白背景 + parts.push(``); + parts.push(``); + parts.push(``); + // 顶部主题色条(很淡的渐变) + parts.push(``); + // 细边框 + parts.push(``); + // 顶部彩色装饰条(小圆点序列或渐变条) + parts.push(``); + + const padX = 24; + const padY = 22; + + // ========== 顶部:大图标 + 类型标签 + 右侧徽章 ========== + const iconSize = 44; + const iconX = x + padX; + const iconY = y + padY; + + // 图标背景(渐变方形) + parts.push(``); + parts.push(``); + parts.push(``); + // 图标 path(白色,缩放) + const iconPath = _acGetNodeIconPath(n.type); + const iconScale = (iconSize * 0.55) / 24; + const iconInnerOffset = (iconSize - 24 * iconScale) / 2; + parts.push(` + + `); + + // 类型标签(在图标右侧) + const typeTextX = iconX + iconSize + 14; + // 类型英文(TYPE LABEL,淡色小字) + const typeEn = n.type === 'target' ? 'TARGET' : n.type === 'action' ? 'ACTION' : n.type === 'vulnerability' ? 'VULNERABILITY' : (n.type || '').toUpperCase(); + parts.push(`${_acEscapeXml(typeEn)}`); + // 类型中文(大字,主要色) + parts.push(`${_acEscapeXml(theme.label)}`); + + // ========== 右上角徽章 ========== + const badgeY = iconY + 2; + const badgeH = 26; + if (n.type === 'vulnerability' && n.riskScore > 0) { + // 风险分数徽章(大号,渐变背景) + const riskLabel = _acGetRiskLabel(n.riskScore); + const badgeText = `${riskLabel} · ${n.riskScore}`; + const badgeW = 90; + const bx = x + n.w - badgeW - padX; + parts.push(``); + parts.push(``); + parts.push(`${_acEscapeXml(badgeText)}`); + parts.push(``); + } else if (n.type === 'action') { + const m = n.metadata || {}; + const findings = m.findings || []; + const hasFindings = Array.isArray(findings) && findings.length > 0; + const isFailed = m.status === 'failed_insight'; + if (hasFindings || isFailed) { + const text = isFailed ? '有线索' : `发现 ${findings.length}`; + const badgeW = 70; + const bx = x + n.w - badgeW - padX; + parts.push(``); + // 小圆点(状态指示) + parts.push(``); + parts.push(`${_acEscapeXml(text)}`); + } + } else if (n.type === 'target') { + // 目标节点显示"目标"标识 + const badgeW = 60; + const bx = x + n.w - badgeW - padX; + parts.push(``); + parts.push(`主目标`); + } + + // ========== 主标题 ========== + const contentTopY = iconY + iconSize + 18; + const titleFontSize = 16; + const titleLineH = titleFontSize + 6; + const contentAvailW = n.w - padX * 2; + const charsPerLine = Math.max(10, Math.floor(contentAvailW / (titleFontSize * 0.58))); + const titleLines = _acWrapLabel(n.label, charsPerLine, 2); + titleLines.forEach((ln, idx) => { + parts.push(`${_acEscapeXml(ln)}`); + }); + + // ========== 底部元信息栏 ========== + const metaY = y + n.h - 22; + // 分隔线 + parts.push(``); + + // 生成元信息文本 + const metaItems = []; + if (n.type === 'target') { + const tgt = (n.metadata && n.metadata.target) ? n.metadata.target : null; + if (tgt) metaItems.push({ icon: 'loc', text: _acTruncateToWidth(tgt, 26) }); + } else if (n.type === 'action') { + const toolName = n.metadata && n.metadata.tool_name; + if (toolName) metaItems.push({ icon: 'tool', text: _acTruncateToWidth(toolName, 20) }); + const intent = n.metadata && n.metadata.tool_intent; + if (intent) metaItems.push({ icon: 'aim', text: _acTruncateToWidth(intent, 22) }); + } else if (n.type === 'vulnerability') { + const vt = n.metadata && n.metadata.vulnerability_type; + if (vt) metaItems.push({ icon: 'shield', text: _acTruncateToWidth(vt, 22) }); + const sev = n.metadata && n.metadata.severity; + if (sev) metaItems.push({ icon: 'alert', text: _acTruncateToWidth(sev, 12) }); + } + if (metaItems.length === 0) { + // 没有元信息时显示节点ID简短版 + metaItems.push({ icon: 'hash', text: _acTruncateToWidth(n.id || '', 20) }); + } + + // 元信息图标 path(24x24) + const metaIconPaths = { + 'loc': 'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5z', + 'tool': 'M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z', + 'aim': 'M12 2L4 5v6c0 5.5 3.8 10.7 8 12 4.2-1.3 8-6.5 8-12V5l-8-3zm4 10H8V9h3V7l3 3-3 3v-1z', + 'shield': 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.4-1.4 2.6 2.6 6.6-6.6L18 9l-8 8z', + 'alert': 'M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z', + 'hash': 'M20 9h-4.5l.9-4h-2l-.9 4H9l.9-4H8l-.9 4H3v2h3.7l-1 4H2v2h3.3l-.9 4h2l.9-4H12l-.9 4h2l.9-4H19v-2h-4.7l1-4H20V9zm-6.3 6H9l1-4h4.7l-1 4z' + }; + + let metaX = x + padX; + metaItems.forEach((mi, idx) => { + if (idx > 0) { + // 分隔符 + parts.push(``); + metaX += 14; + } + // 图标 + const path = metaIconPaths[mi.icon] || metaIconPaths.hash; + parts.push(` + + `); + metaX += 18; + // 文本 + parts.push(`${_acEscapeXml(mi.text)}`); + metaX += mi.text.length * 6.5; // 粗略估算 + }); + }); + + parts.push(``); + + // ==================== 右侧图例 ==================== + const lx = legendX; + const ly = graphAreaY; + const lw = LEGEND_W - 16; + const lh = graphAreaH; + + // 图例主卡片 + parts.push(``); + // 顶部彩色装饰 + parts.push(``); + + let curY = ly + 26; + + // --- 节点类型 --- + parts.push(`NODE TYPES · 节点类型`); + curY += 22; + const typeSummary = [ + { key: 'target', count: targetNodes.length, text: '目标' }, + { key: 'action-success', count: actionNodes.filter(a => { const m = a.metadata || {}; return Array.isArray(m.findings) && m.findings.length > 0 && m.status !== 'failed_insight'; }).length, text: '行动(有发现)' }, + { key: 'action-neutral', count: actionNodes.filter(a => { const m = a.metadata || {}; const f = Array.isArray(m.findings) ? m.findings : []; return f.length === 0 || m.status === 'failed_insight'; }).length, text: '行动(其他)' }, + { key: 'vuln-critical', count: criticalCount, text: '严重漏洞' }, + { key: 'vuln-high', count: highCount, text: '高风险漏洞' }, + { key: 'vuln-med', count: medCount, text: '中风险漏洞' }, + { key: 'vuln-low', count: lowCount, text: '低风险漏洞' } + ]; + typeSummary.forEach(item => { + const t = typeTheme[item.key]; + if (item.count === 0) return; // 不显示零计数项 + // 图标方块 + parts.push(``); + // 标签文本 + parts.push(`${_acEscapeXml(item.text)}`); + // 计数 + parts.push(`${item.count}`); + curY += 22; + }); + curY += 10; + + // --- 连线含义 --- + parts.push(`CONNECTIONS · 连线含义`); + curY += 22; + const lineItems = [ + { label: '行动发现漏洞', color: '#4F46E5', dash: '' }, + { label: '使能 / 促成关系', color: '#E11D48', dash: '' }, + { label: '逻辑顺序', color: '#64748B', dash: '' }, + { label: '目标定位', color: '#4F46E5', dash: '6,3' } + ]; + lineItems.forEach(l => { + const dashAttr = l.dash ? `stroke-dasharray="${l.dash}"` : ''; + parts.push(``); + parts.push(``); + parts.push(`${_acEscapeXml(l.label)}`); + curY += 24; + }); + curY += 10; + + // --- 风险等级条 --- + parts.push(`RISK LEVELS · 风险等级`); + curY += 22; + const riskBar = [ + { label: '严重', range: '80-100', color: '#E11D48' }, + { label: '高', range: '60-79', color: '#EA580C' }, + { label: '中', range: '40-59', color: '#CA8A04' }, + { label: '低', range: '0-39', color: '#0D9488' } + ]; + riskBar.forEach(r => { + // 风险等级胶囊 + parts.push(``); + parts.push(`${_acEscapeXml(r.label)}`); + // 分数范围 + parts.push(`分数 ${_acEscapeXml(r.range)}`); + curY += 26; + }); + + // ==================== 底部信息栏 ==================== + const fY = OUTER_PAD + contentH - FOOTER_H; + // 分隔线 + parts.push(``); + // 左侧品牌 + parts.push(``); + parts.push(`CyberStrikeAI · Attack Chain Visualization Report`); + // 右侧时间戳 + parts.push(`${_acEscapeXml(ts)}`); + + parts.push(``); + return parts.join('\n'); +} + +// 下载文本文件 +function _acDownloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 150); +} + +// 基于 SVG 字符串生成高清 PNG +function _acSvgToPng(svgString, scale) { + return new Promise((resolve, reject) => { + try { + // 读取 SVG 尺寸 + const m = svgString.match(/]*width="(\d+(?:\.\d+)?)"[^>]*height="(\d+(?:\.\d+)?)"/i); + const w = m ? parseFloat(m[1]) : 1600; + const h = m ? parseFloat(m[2]) : 900; + const s = scale || Math.min(2.5, Math.max(1.5, 2000 / Math.max(w, h))); + + const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = function () { + try { + const canvas = document.createElement('canvas'); + canvas.width = Math.round(w * s); + canvas.height = Math.round(h * s); + const ctx = canvas.getContext('2d'); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + URL.revokeObjectURL(url); + canvas.toBlob(pngBlob => { + if (!pngBlob) reject(new Error('PNG 生成失败')); + else resolve(pngBlob); + }, 'image/png', 0.95); + } catch (err) { + URL.revokeObjectURL(url); + reject(err); + } + }; + img.onerror = function (e) { + URL.revokeObjectURL(url); + reject(new Error('SVG 加载失败')); + }; + img.src = url; + } catch (e) { + reject(e); + } + }); +} + +// 导出攻击链(美化版) function exportAttackChain(format) { if (!attackChainCytoscape) { - alert('请先加载攻击链'); + alert(typeof window.t === 'function' ? window.t('chat.pleaseLoadAttackChainFirst', {}, '请先加载攻击链') : '请先加载攻击链'); return; } - - // 确保图形已经渲染完成(使用小延迟) + + // 延时确保渲染完成 setTimeout(() => { try { - if (format === 'png') { - try { - const pngPromise = attackChainCytoscape.png({ - output: 'blob', - bg: 'white', - full: true, - scale: 1 - }); - - // 处理 Promise - if (pngPromise && typeof pngPromise.then === 'function') { - pngPromise.then(blob => { - if (!blob) { - throw new Error('PNG导出返回空数据'); + const svgString = _acBuildSvgString(); + const convId = currentAttackChainConversationId || 'export'; + const tsName = Date.now(); + + if (format === 'svg') { + const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); + _acDownloadBlob(blob, `attack-chain-${convId}-${tsName}.svg`); + } else if (format === 'png') { + _acSvgToPng(svgString, 2) + .then(pngBlob => _acDownloadBlob(pngBlob, `attack-chain-${convId}-${tsName}.png`)) + .catch(err => { + console.error('导出 PNG 失败,回退到 Cytoscape 原生导出:', err); + // 回退方案:使用 Cytoscape 自带导出 + try { + const p = attackChainCytoscape.png({ output: 'blob', bg: '#ffffff', full: true, scale: 2 }); + if (p && typeof p.then === 'function') { + p.then(b => _acDownloadBlob(b, `attack-chain-${convId}-${tsName}.png`)) + .catch(e => alert('导出 PNG 失败: ' + (e && e.message || e))); + } else if (p) { + _acDownloadBlob(p, `attack-chain-${convId}-${tsName}.png`); + } else { + alert('导出 PNG 失败'); } - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `attack-chain-${currentAttackChainConversationId || 'export'}-${Date.now()}.png`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(url), 100); - }).catch(err => { - console.error('导出PNG失败:', err); - alert('导出PNG失败: ' + (err.message || '未知错误')); - }); - } else { - // 如果不是 Promise,直接使用 - const url = URL.createObjectURL(pngPromise); - const a = document.createElement('a'); - a.href = url; - a.download = `attack-chain-${currentAttackChainConversationId || 'export'}-${Date.now()}.png`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(url), 100); - } - } catch (err) { - console.error('PNG导出错误:', err); - alert('导出PNG失败: ' + (err.message || '未知错误')); - } - } else if (format === 'svg') { - try { - // Cytoscape.js 3.x 不直接支持 .svg() 方法 - // 使用替代方案:从 Cytoscape 数据手动构建 SVG - const container = attackChainCytoscape.container(); - if (!container) { - throw new Error('无法获取容器元素'); - } - - // 获取所有节点和边 - const nodes = attackChainCytoscape.nodes(); - const edges = attackChainCytoscape.edges(); - - if (nodes.length === 0) { - throw new Error('没有节点可导出'); - } - - // 计算所有节点的实际边界(包括节点大小) - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - nodes.forEach(node => { - const pos = node.position(); - const nodeWidth = node.width(); - const nodeHeight = node.height(); - const size = Math.max(nodeWidth, nodeHeight) / 2; - - minX = Math.min(minX, pos.x - size); - minY = Math.min(minY, pos.y - size); - maxX = Math.max(maxX, pos.x + size); - maxY = Math.max(maxY, pos.y + size); - }); - - // 也考虑边的范围 - edges.forEach(edge => { - const { source, target, valid } = getEdgeNodes(edge); - if (valid) { - const sourcePos = source.position(); - const targetPos = target.position(); - minX = Math.min(minX, sourcePos.x, targetPos.x); - minY = Math.min(minY, sourcePos.y, targetPos.y); - maxX = Math.max(maxX, sourcePos.x, targetPos.x); - maxY = Math.max(maxY, sourcePos.y, targetPos.y); + } catch (e2) { + alert('导出 PNG 失败: ' + (e2 && e2.message || e2)); } }); - - // 添加边距 - const padding = 50; - minX -= padding; - minY -= padding; - maxX += padding; - maxY += padding; - - const width = maxX - minX; - const height = maxY - minY; - - // 创建 SVG 元素 - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('width', width.toString()); - svg.setAttribute('height', height.toString()); - svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); - svg.setAttribute('viewBox', `${minX} ${minY} ${width} ${height}`); - - // 添加白色背景矩形 - const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - bgRect.setAttribute('x', minX.toString()); - bgRect.setAttribute('y', minY.toString()); - bgRect.setAttribute('width', width.toString()); - bgRect.setAttribute('height', height.toString()); - bgRect.setAttribute('fill', 'white'); - svg.appendChild(bgRect); - - // 创建 defs 用于箭头标记 - const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); - - // 添加边的箭头标记(为不同类型的边创建不同的箭头) - const edgeTypes = ['discovers', 'targets', 'enables', 'leads_to']; - edgeTypes.forEach((type, index) => { - let color = '#999'; - if (type === 'discovers') color = '#3498db'; - else if (type === 'targets') color = '#0066ff'; - else if (type === 'enables') color = '#e74c3c'; - else if (type === 'leads_to') color = '#666'; - - const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); - marker.setAttribute('id', `arrowhead-${type}`); - marker.setAttribute('markerWidth', '10'); - marker.setAttribute('markerHeight', '10'); - marker.setAttribute('refX', '9'); - marker.setAttribute('refY', '3'); - marker.setAttribute('orient', 'auto'); - const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); - polygon.setAttribute('points', '0 0, 10 3, 0 6'); - polygon.setAttribute('fill', color); - marker.appendChild(polygon); - defs.appendChild(marker); - }); - svg.appendChild(defs); - - // 添加边(先绘制,这样节点会在上面) - edges.forEach(edge => { - const { source, target, valid } = getEdgeNodes(edge); - if (!valid) { - return; // 跳过无效的边 - } - - const sourcePos = source.position(); - const targetPos = target.position(); - const edgeData = edge.data(); - const edgeType = edgeData.type || 'leads_to'; - - // 获取边的样式 - let lineColor = '#999'; - if (edgeType === 'discovers') lineColor = '#3498db'; - else if (edgeType === 'targets') lineColor = '#0066ff'; - else if (edgeType === 'enables') lineColor = '#e74c3c'; - else if (edgeType === 'leads_to') lineColor = '#666'; - - // 创建路径(支持曲线) - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - // 简单的直线路径(可以改进为曲线) - const midX = (sourcePos.x + targetPos.x) / 2; - const midY = (sourcePos.y + targetPos.y) / 2; - const dx = targetPos.x - sourcePos.x; - const dy = targetPos.y - sourcePos.y; - const offset = Math.min(30, Math.sqrt(dx * dx + dy * dy) * 0.3); - - // 使用二次贝塞尔曲线 - const controlX = midX + (dy > 0 ? -offset : offset); - const controlY = midY + (dx > 0 ? offset : -offset); - path.setAttribute('d', `M ${sourcePos.x} ${sourcePos.y} Q ${controlX} ${controlY} ${targetPos.x} ${targetPos.y}`); - path.setAttribute('stroke', lineColor); - path.setAttribute('stroke-width', '2'); - path.setAttribute('fill', 'none'); - path.setAttribute('marker-end', `url(#arrowhead-${edgeType})`); - svg.appendChild(path); - }); - - // 添加节点 - nodes.forEach(node => { - const pos = node.position(); - const nodeData = node.data(); - const riskScore = nodeData.riskScore || 0; - const nodeWidth = node.width(); - const nodeHeight = node.height(); - const size = Math.max(nodeWidth, nodeHeight) / 2; - - // 确定节点颜色 - let bgColor = '#88cc00'; - let textColor = '#1a5a1a'; - let borderColor = '#5a8a5a'; - if (riskScore >= 80) { - bgColor = '#ff4444'; - textColor = '#fff'; - borderColor = '#fff'; - } else if (riskScore >= 60) { - bgColor = '#ff8800'; - textColor = '#fff'; - borderColor = '#fff'; - } else if (riskScore >= 40) { - bgColor = '#ffbb00'; - textColor = '#333'; - borderColor = '#cc9900'; - } - - // 确定节点形状 - const nodeType = nodeData.type; - let shapeElement; - if (nodeType === 'vulnerability') { - // 菱形 - shapeElement = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); - const points = [ - `${pos.x},${pos.y - size}`, - `${pos.x + size},${pos.y}`, - `${pos.x},${pos.y + size}`, - `${pos.x - size},${pos.y}` - ].join(' '); - shapeElement.setAttribute('points', points); - } else if (nodeType === 'target') { - // 星形(五角星) - shapeElement = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); - const points = []; - for (let i = 0; i < 5; i++) { - const angle = (i * 4 * Math.PI / 5) - Math.PI / 2; - const x = pos.x + size * Math.cos(angle); - const y = pos.y + size * Math.sin(angle); - points.push(`${x},${y}`); - } - shapeElement.setAttribute('points', points.join(' ')); - } else { - // 圆角矩形 - shapeElement = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); - shapeElement.setAttribute('x', (pos.x - size).toString()); - shapeElement.setAttribute('y', (pos.y - size).toString()); - shapeElement.setAttribute('width', (size * 2).toString()); - shapeElement.setAttribute('height', (size * 2).toString()); - shapeElement.setAttribute('rx', '5'); - shapeElement.setAttribute('ry', '5'); - } - - shapeElement.setAttribute('fill', bgColor); - shapeElement.setAttribute('stroke', borderColor); - shapeElement.setAttribute('stroke-width', '2'); - svg.appendChild(shapeElement); - - // 添加文本标签(使用文本描边提高可读性) - // 使用原始标签,不包含类型标签前缀 - const label = (nodeData.originalLabel || nodeData.label || nodeData.id || '').toString(); - const maxLength = 15; - - // 创建文本组,包含描边和填充 - const textGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - textGroup.setAttribute('text-anchor', 'middle'); - textGroup.setAttribute('dominant-baseline', 'middle'); - - // 处理长文本(简单换行) - let lines = []; - if (label.length > maxLength) { - const words = label.split(' '); - let currentLine = ''; - words.forEach(word => { - if ((currentLine + word).length <= maxLength) { - currentLine += (currentLine ? ' ' : '') + word; - } else { - if (currentLine) lines.push(currentLine); - currentLine = word; - } - }); - if (currentLine) lines.push(currentLine); - lines = lines.slice(0, 2); // 最多两行 - } else { - lines = [label]; - } - - // 确定文本描边颜色(与原始渲染一致) - let textOutlineColor = '#fff'; - let textOutlineWidth = 2; - if (riskScore >= 80 || riskScore >= 60) { - // 红色/橙色背景:白色文字,白色描边,深色轮廓 - textOutlineColor = '#333'; - textOutlineWidth = 1; - } else if (riskScore >= 40) { - // 黄色背景:深色文字,白色描边 - textOutlineColor = '#fff'; - textOutlineWidth = 2; - } else { - // 绿色背景:深绿色文字,白色描边 - textOutlineColor = '#fff'; - textOutlineWidth = 2; - } - - // 为每行文本创建描边和填充 - lines.forEach((line, i) => { - const textY = pos.y + (i - (lines.length - 1) / 2) * 16; - - // 描边文本(用于提高对比度,模拟text-outline效果) - const strokeText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - strokeText.setAttribute('x', pos.x.toString()); - strokeText.setAttribute('y', textY.toString()); - strokeText.setAttribute('fill', 'none'); - strokeText.setAttribute('stroke', textOutlineColor); - strokeText.setAttribute('stroke-width', textOutlineWidth.toString()); - strokeText.setAttribute('stroke-linejoin', 'round'); - strokeText.setAttribute('stroke-linecap', 'round'); - strokeText.setAttribute('font-size', '14px'); - strokeText.setAttribute('font-weight', 'bold'); - strokeText.setAttribute('font-family', 'Arial, sans-serif'); - strokeText.setAttribute('text-anchor', 'middle'); - strokeText.setAttribute('dominant-baseline', 'middle'); - strokeText.textContent = line; - textGroup.appendChild(strokeText); - - // 填充文本(实际可见的文本) - const fillText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - fillText.setAttribute('x', pos.x.toString()); - fillText.setAttribute('y', textY.toString()); - fillText.setAttribute('fill', textColor); - fillText.setAttribute('font-size', '14px'); - fillText.setAttribute('font-weight', 'bold'); - fillText.setAttribute('font-family', 'Arial, sans-serif'); - fillText.setAttribute('text-anchor', 'middle'); - fillText.setAttribute('dominant-baseline', 'middle'); - fillText.textContent = line; - textGroup.appendChild(fillText); - }); - - svg.appendChild(textGroup); - }); - - // 将 SVG 转换为字符串 - const serializer = new XMLSerializer(); - let svgString = serializer.serializeToString(svg); - - // 确保有 XML 声明 - if (!svgString.startsWith('\n' + svgString; - } - - const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `attack-chain-${currentAttackChainConversationId || 'export'}-${Date.now()}.svg`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(url), 100); - } catch (err) { - console.error('SVG导出错误:', err); - alert('导出SVG失败: ' + (err.message || '未知错误')); - } } else { alert('不支持的导出格式: ' + format); } } catch (error) { console.error('导出失败:', error); - alert('导出失败: ' + (error.message || '未知错误')); + alert('导出失败: ' + (error && error.message || '未知错误')); } - }, 100); // 小延迟确保图形已渲染 + }, 80); } // ============================================ diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index 3d3ba16b..d38cb8dc 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -39,6 +39,100 @@ let webshellStreamingTypingId = 0; let webshellProbeStatusById = {}; let webshellBatchProbeRunning = false; +/** 允许的响应编码,与后端 normalizeWebshellEncoding 对齐 */ +const WEBSHELL_ALLOWED_ENCODINGS = ['auto', 'utf-8', 'gbk', 'gb18030']; + +/** 归一化连接的 encoding 字段,返回 'auto' | 'utf-8' | 'gbk' | 'gb18030'(空/未知 → auto) */ +function normalizeWebshellEncoding(v) { + var s = (v == null ? '' : String(v)).trim().toLowerCase(); + if (s === 'utf8') s = 'utf-8'; + if (!s) return 'auto'; + return WEBSHELL_ALLOWED_ENCODINGS.indexOf(s) >= 0 ? s : 'auto'; +} + +/** 从连接对象取编码,便于透传到 /api/webshell/exec 与 /api/webshell/file */ +function webshellConnEncoding(conn) { + return normalizeWebshellEncoding(conn && conn.encoding); +} + +/** 允许的目标 OS,与后端 normalizeWebshellOS 对齐 */ +const WEBSHELL_ALLOWED_OS = ['auto', 'linux', 'windows']; + +/** 归一化连接的 os 字段,返回 'auto' | 'linux' | 'windows'(空/未知 → auto) */ +function normalizeWebshellOS(v) { + var s = (v == null ? '' : String(v)).trim().toLowerCase(); + if (!s) return 'auto'; + return WEBSHELL_ALLOWED_OS.indexOf(s) >= 0 ? s : 'auto'; +} + +/** 从连接对象取目标 OS,便于透传到 /api/webshell/exec 与 /api/webshell/file */ +function webshellConnOS(conn) { + return normalizeWebshellOS(conn && conn.os); +} + +/** + * 组装 /api/webshell/file 的公共请求体。 + * 所有文件管理调用点都应走此函数,避免遗漏字段(如 connection_id)。 + * @param {Object} conn 连接对象 + * @param {Object} extra 额外字段(action / path / content / target_path / chunk_index ...) + * @returns {string} JSON 字符串 + */ +function webshellFileRequestBody(conn, extra) { + const base = { + url: conn.url, + password: conn.password || '', + type: conn.type || 'php', + method: (conn.method || 'post').toLowerCase(), + cmd_param: conn.cmdParam || '', + encoding: webshellConnEncoding(conn), + os: webshellConnOS(conn), + connection_id: conn.id || '' + }; + const merged = Object.assign(base, extra || {}); + return JSON.stringify(merged); +} + +/** + * 当服务端探活命中目标系统(仅 auto 连接首次列目录时出现)时, + * 把结果同步到本地 webshellConnections 缓存 + 持久化到数据库。 + * 后续刷新不再探活,AI 也能直接看到正确的 OS 上下文。 + */ +function applyWebshellDetectedOS(conn, data) { + if (!conn || !data || !data.detected_os) return; + const detected = normalizeWebshellOS(data.detected_os); + if (detected !== 'linux' && detected !== 'windows') return; + if (webshellConnOS(conn) !== 'auto') return; // 用户已显式配置,尊重之 + conn.os = detected; + if (Array.isArray(webshellConnections)) { + for (var i = 0; i < webshellConnections.length; i++) { + if (webshellConnections[i] && webshellConnections[i].id === conn.id) { + webshellConnections[i].os = detected; + break; + } + } + } + if (typeof renderWebshellList === 'function') { + try { renderWebshellList(); } catch (e) {} + } + // 服务端已经回写了 DB;但极少数情况下调用方未带 connection_id,这里再兜底 PUT 一次 + if (conn.id && typeof apiFetch === 'function') { + apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + url: conn.url, + password: conn.password || '', + type: conn.type || 'php', + method: conn.method || 'post', + cmd_param: conn.cmdParam || '', + remark: conn.remark || '', + encoding: conn.encoding || 'auto', + os: detected + }) + }).catch(function () {}); + } +} + /** 与主对话页一致:Eino 模式走 /api/multi-agent/stream,body 带 orchestration */ function resolveWebshellAiStreamRequest() { if (typeof apiFetch === 'undefined') { @@ -335,6 +429,17 @@ function wsT(key) { 'webshell.addConnection': '添加连接', 'webshell.cmdParam': '命令参数名', 'webshell.cmdParamPlaceholder': '不填默认为 cmd,如填 xxx 则请求为 xxx=命令', + 'webshell.encoding': '响应编码', + 'webshell.encodingAuto': '自动检测', + 'webshell.encodingUtf8': 'UTF-8', + 'webshell.encodingGbk': 'GBK(中文 Windows)', + 'webshell.encodingGb18030': 'GB18030', + 'webshell.encodingHint': '中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030', + 'webshell.os': '目标系统', + 'webshell.osAuto': '自动(按 Shell 类型推断)', + 'webshell.osLinux': 'Linux / Unix', + 'webshell.osWindows': 'Windows', + 'webshell.osHint': '决定文件管理/上传使用 Linux 还是 Windows 命令;PHP/JSP 跑在 Windows 上请选 Windows', 'webshell.connections': '连接列表', 'webshell.noConnections': '暂无连接,请点击「添加连接」', 'webshell.selectOrAdd': '请从左侧选择连接,或添加新的 WebShell 连接', @@ -661,9 +766,20 @@ function renderWebshellList() { } else if (probe && probe.state === 'fail') { probeHtml = '' + (wsT('webshell.probeOffline') || '离线') + ''; } + var encNorm = normalizeWebshellEncoding(conn.encoding); + var encHtml = ''; + if (encNorm && encNorm !== 'auto') { + encHtml = '' + escapeHtml(encNorm.toUpperCase()) + ''; + } + var osNorm = normalizeWebshellOS(conn.os); + var osHtml = ''; + if (osNorm && osNorm !== 'auto') { + var osLabel = osNorm === 'windows' ? 'WIN' : 'LINUX'; + osHtml = '' + osLabel + ''; + } return ( '
' + - '
' + remark + '
' + probeHtml + '
' + + '
' + remark + '
' + probeHtml + osHtml + encHtml + '
' + '
' + url + '
' + '
' + '
' + actionsLabel + '' + @@ -709,6 +825,8 @@ function probeWebshellConnection(conn) { type: conn.type || 'php', method: ((conn.method || 'post').toLowerCase() === 'get') ? 'get' : 'post', cmd_param: conn.cmdParam || '', + encoding: webshellConnEncoding(conn), + os: webshellConnOS(conn), command: 'echo 1' }) }) @@ -3365,6 +3483,8 @@ function execWebshellCommand(conn, command) { type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', + encoding: webshellConnEncoding(conn), + os: webshellConnOS(conn), command: command }) }).then(function (r) { return r.json(); }) @@ -3391,17 +3511,10 @@ function webshellFileListDir(conn, path) { 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 - }) + body: webshellFileRequestBody(conn, { action: 'list', path: path }) }).then(function (r) { return r.json(); }) .then(function (data) { + applyWebshellDetectedOS(conn, data); if (!data.ok && data.error) { listEl.innerHTML = '
' + escapeHtml(data.error) + '
' + escapeHtml(data.output || '') + '
'; return; @@ -3497,16 +3610,9 @@ function fetchWebshellDirectoryItems(conn, path) { 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 - }) + body: webshellFileRequestBody(conn, { action: 'list', path: path }) }).then(function (r) { return r.json(); }).then(function (data) { + applyWebshellDetectedOS(conn, data); if (!data || data.error || !data.ok) return []; return parseWebshellListItems(data.output || ''); }).catch(function () { @@ -3801,7 +3907,7 @@ function webshellFileMkdir(conn, pathInput) { 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 }) }) + apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: webshellFileRequestBody(conn, { action: 'mkdir', path: path }) }) .then(function (r) { return r.json(); }) .then(function () { webshellFileListDir(conn, base); }) .catch(function () { webshellFileListDir(conn, base); }); @@ -3848,7 +3954,7 @@ function webshellFileUpload(conn, pathInput) { 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 }) }) + apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: webshellFileRequestBody(conn, { 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(); }); @@ -3867,7 +3973,7 @@ function webshellFileRename(conn, oldPath, oldName, listEl) { 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 }) }) + apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: webshellFileRequestBody(conn, { 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() || '.'); }); @@ -3906,7 +4012,7 @@ function webshellFileDownload(conn, path) { 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 }) + body: webshellFileRequestBody(conn, { action: 'read', path: path }) }).then(function (r) { return r.json(); }) .then(function (data) { var content = (data && data.output) != null ? data.output : (data.error || ''); @@ -3927,7 +4033,7 @@ function webshellFileRead(conn, path, listEl, browsePath) { 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 }) + body: webshellFileRequestBody(conn, { action: 'read', path: path }) }).then(function (r) { return r.json(); }) .then(function (data) { const out = (data && data.output) ? data.output : (data.error || ''); @@ -3956,7 +4062,7 @@ function webshellFileEdit(conn, path, listEl) { 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 }) + body: webshellFileRequestBody(conn, { action: 'read', path: path }) }).then(function (r) { return r.json(); }) .then(function (data) { const content = (data && data.output) ? data.output : (data.error || ''); @@ -3992,7 +4098,7 @@ function webshellFileWrite(conn, path, content, onDone, listEl) { 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 }) + body: webshellFileRequestBody(conn, { action: 'write', path: path, content: content }) }).then(function (r) { return r.json(); }) .then(function (data) { if (data && !data.ok && data.error && listEl) { @@ -4011,7 +4117,7 @@ function webshellFileDelete(conn, path, onDone) { 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 }) + body: webshellFileRequestBody(conn, { action: 'delete', path: path }) }).then(function (r) { return r.json(); }) .then(function () { if (onDone) onDone(); }) .catch(function () { if (onDone) onDone(); }); @@ -4063,6 +4169,10 @@ function showAddWebshellModal() { document.getElementById('webshell-type').value = 'php'; document.getElementById('webshell-method').value = 'post'; document.getElementById('webshell-cmd-param').value = ''; + var osSelEl = document.getElementById('webshell-os'); + if (osSelEl) osSelEl.value = 'auto'; + var encSelEl = document.getElementById('webshell-encoding'); + if (encSelEl) encSelEl.value = 'auto'; document.getElementById('webshell-remark').value = ''; var titleEl = document.getElementById('webshell-modal-title'); if (titleEl) titleEl.textContent = wsT('webshell.addConnection'); @@ -4081,6 +4191,10 @@ function showEditWebshellModal(connId) { 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 || ''; + var osEditEl = document.getElementById('webshell-os'); + if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os); + var encEditEl = document.getElementById('webshell-encoding'); + if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding); document.getElementById('webshell-remark').value = conn.remark || ''; var titleEl = document.getElementById('webshell-modal-title'); if (titleEl) titleEl.textContent = wsT('webshell.editConnectionTitle'); @@ -4308,6 +4422,8 @@ function testWebshellConnection() { 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 osTag = normalizeWebshellOS((document.getElementById('webshell-os') || {}).value); + var encoding = normalizeWebshellEncoding((document.getElementById('webshell-encoding') || {}).value); var btn = document.getElementById('webshell-test-btn'); if (btn) { btn.disabled = true; btn.textContent = (typeof wsT === 'function' ? wsT('common.refresh') : '刷新') + '...'; } if (typeof apiFetch === 'undefined') { @@ -4315,6 +4431,7 @@ function testWebshellConnection() { alert(wsT('webshell.testFailed') || '连通性测试失败'); return; } + // 连通性使用 Windows/Linux 都识别的最小内建命令作为探测(echo 1 在 cmd 和 sh 下行为等价) apiFetch('/api/webshell/exec', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -4324,6 +4441,8 @@ function testWebshellConnection() { type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam || '', + encoding: encoding, + os: osTag, command: 'echo 1' }) }) @@ -4369,12 +4488,14 @@ function saveWebshellConnection() { 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 osTag = normalizeWebshellOS((document.getElementById('webshell-os') || {}).value); + var encoding = normalizeWebshellEncoding((document.getElementById('webshell-encoding') || {}).value); 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 }; + var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, encoding: encoding, os: osTag, remark: remark || url }; if (typeof apiFetch === 'undefined') return; var reqUrl = editId ? ('/api/webshell/connections/' + encodeURIComponent(editId)) : '/api/webshell/connections'; diff --git a/web/templates/index.html b/web/templates/index.html index c33e9700..99ebdf0a 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -2925,6 +2925,25 @@
+
+ + + 决定文件管理/上传使用 Linux 还是 Windows 命令;PHP/JSP 跑在 Windows 上请选 Windows +
+
+ + + 中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030 +