Compare commits

..

1 Commits

Author SHA1 Message Date
公明 46a7d338a4 Add files via upload 2026-06-20 17:25:44 +08:00
6 changed files with 1571 additions and 27 deletions
+518 -21
View File
@@ -23860,9 +23860,17 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
min-height: 420px; min-height: 420px;
} }
.projects-placeholder-icon { .projects-placeholder-icon {
font-size: 3rem; display: flex;
margin-bottom: 16px; align-items: center;
opacity: 0.85; justify-content: center;
width: 88px;
height: 88px;
margin-bottom: 20px;
color: #3b82f6;
background: linear-gradient(145deg, #eff6ff 0%, #dbeafe 100%);
border: 1px solid #bfdbfe;
border-radius: 22px;
box-shadow: 0 8px 24px rgba(59, 130, 246, 0.12);
} }
.projects-detail-placeholder h3 { .projects-detail-placeholder h3 {
margin: 0 0 8px; margin: 0 0 8px;
@@ -23883,7 +23891,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
background: #ffffff; background: #ffffff;
border: 1px solid var(--border-color, #e2e8f0); border: 1px solid var(--border-color, #e2e8f0);
border-radius: 14px; border-radius: 14px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 8px 24px rgba(15, 23, 42, 0.04);
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
align-self: stretch; align-self: stretch;
@@ -24066,6 +24074,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
color: #0066ff; color: #0066ff;
background: #fff; background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
font-weight: 600;
} }
.projects-panel { .projects-panel {
flex: 1; flex: 1;
@@ -24309,11 +24318,17 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
#project-panel-vulns .projects-table-wrap { #project-panel-vulns .projects-table-wrap {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
#project-panel-conversations .projects-table-wrap,
#project-panel-vulns .projects-table-wrap {
overflow-x: hidden;
}
#project-panel-facts .projects-table-wrap {
overflow-x: auto;
}
#project-panel-facts .projects-table-wrap .data-table--projects thead th, #project-panel-facts .projects-table-wrap .data-table--projects thead th,
#project-panel-conversations .projects-table-wrap .data-table--projects thead th, #project-panel-conversations .projects-table-wrap .data-table--projects thead th,
#project-panel-vulns .projects-table-wrap .data-table--projects thead th { #project-panel-vulns .projects-table-wrap .data-table--projects thead th {
@@ -24332,12 +24347,6 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
.projects-panel-toolbar--hint .projects-fact-toolbar-hint { .projects-panel-toolbar--hint .projects-fact-toolbar-hint {
margin: 0; margin: 0;
} }
#project-panel-facts .data-table--projects th:nth-child(1) { width: 20%; }
#project-panel-facts .data-table--projects th:nth-child(2) { width: 9%; }
#project-panel-facts .data-table--projects th:nth-child(3) { width: 30%; }
#project-panel-facts .data-table--projects th:nth-child(4) { width: 9%; }
#project-panel-facts .data-table--projects th:nth-child(5) { width: 10%; }
#project-panel-facts .data-table--projects th:nth-child(6) { width: 10%; }
#project-panel-facts .data-table--projects .cell-fact-key { #project-panel-facts .data-table--projects .cell-fact-key {
overflow: hidden; overflow: hidden;
max-width: 0; max-width: 0;
@@ -24345,6 +24354,16 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
#project-panel-facts .data-table--projects .cell-fact-category { #project-panel-facts .data-table--projects .cell-fact-category {
white-space: nowrap; white-space: nowrap;
} }
#project-panel-facts .data-table--projects .cell-summary {
max-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#project-panel-facts .data-table--projects .cell-fact-links {
text-align: center;
white-space: nowrap;
}
#project-panel-facts .projects-fact-key-chip { #project-panel-facts .projects-fact-key-chip {
display: inline-block; display: inline-block;
max-width: 100%; max-width: 100%;
@@ -24463,23 +24482,23 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
} }
#project-panel-facts .data-table--projects th:nth-child(1), #project-panel-facts .data-table--projects th:nth-child(1),
#project-panel-facts .data-table--projects td:nth-child(1) { #project-panel-facts .data-table--projects td:nth-child(1) {
width: 19%; width: 13%;
} }
#project-panel-facts .data-table--projects th:nth-child(2), #project-panel-facts .data-table--projects th:nth-child(2),
#project-panel-facts .data-table--projects td:nth-child(2) { #project-panel-facts .data-table--projects td:nth-child(2) {
width: 9%; width: 7%;
} }
#project-panel-facts .data-table--projects th:nth-child(3), #project-panel-facts .data-table--projects th:nth-child(3),
#project-panel-facts .data-table--projects td:nth-child(3) { #project-panel-facts .data-table--projects td:nth-child(3) {
width: 28%; width: 22%;
} }
#project-panel-facts .data-table--projects th:nth-child(4), #project-panel-facts .data-table--projects th:nth-child(4),
#project-panel-facts .data-table--projects td:nth-child(4) { #project-panel-facts .data-table--projects td:nth-child(4) {
width: 8%; width: 5%;
} }
#project-panel-facts .data-table--projects th:nth-child(5), #project-panel-facts .data-table--projects th:nth-child(5),
#project-panel-facts .data-table--projects td:nth-child(5) { #project-panel-facts .data-table--projects td:nth-child(5) {
width: 9%; width: 8%;
} }
#project-panel-facts .data-table--projects th:nth-child(6), #project-panel-facts .data-table--projects th:nth-child(6),
#project-panel-facts .data-table--projects td:nth-child(6) { #project-panel-facts .data-table--projects td:nth-child(6) {
@@ -24487,8 +24506,485 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
} }
#project-panel-facts .data-table--projects th:nth-child(7), #project-panel-facts .data-table--projects th:nth-child(7),
#project-panel-facts .data-table--projects td:nth-child(7) { #project-panel-facts .data-table--projects td:nth-child(7) {
width: 19%; width: 9%;
} }
#project-panel-facts .data-table--projects th.col-actions,
#project-panel-facts .data-table--projects td.col-actions {
width: 28%;
min-width: 196px;
max-width: 240px;
position: sticky;
right: 0;
z-index: 3;
background: #fff;
box-shadow: -6px 0 10px rgba(15, 23, 42, 0.05);
}
#project-panel-facts .data-table--projects thead th.col-actions {
z-index: 6;
background: #f8fafc;
}
#project-panel-facts .data-table--projects tbody tr:hover td.col-actions {
background: #f8fafc;
}
#project-panel-facts .data-table--projects .col-actions .projects-table-actions {
flex-wrap: nowrap;
}
/* 项目事实攻击路径图 */
#project-panel-graph.projects-panel--graph {
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
padding-bottom: 16px;
}
#project-panel-graph .projects-graph-toolbar {
flex: 0 0 auto;
}
#project-panel-graph .project-fact-graph-layout {
flex: 1 1 auto;
min-height: 0;
}
.projects-graph-toolbar-row {
align-items: flex-end;
}
.projects-graph-search-field {
flex: 1 1 180px;
max-width: 280px;
}
.projects-graph-actions {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
padding: 3px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.projects-graph-action-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 11px;
font-size: 0.8125rem;
font-weight: 500;
color: #475569;
background: transparent;
border: 1px solid transparent;
border-radius: 7px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.projects-graph-action-btn svg {
flex-shrink: 0;
opacity: 0.75;
}
.projects-graph-action-btn:hover {
color: #0f172a;
background: #fff;
border-color: #e2e8f0;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.projects-graph-action-btn--connect {
color: #4338ca;
background: #eef2ff;
border-color: #c7d2fe;
}
.projects-graph-action-btn--connect:hover,
.projects-graph-action-btn--connect-active {
color: #fff;
background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
border-color: transparent;
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.35);
}
.projects-graph-legend {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 14px;
padding-top: 2px;
}
.projects-graph-legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.75rem;
color: #64748b;
}
.projects-graph-legend-item i {
display: inline-block;
width: 22px;
height: 0;
border-top: 2.5px solid var(--legend-color, #cbd5e1);
border-radius: 2px;
}
.projects-graph-legend-item--dashed i {
border-top-style: dashed;
opacity: 0.7;
}
.project-fact-graph-layout {
position: relative;
display: flex;
min-height: 480px;
align-items: stretch;
}
.project-fact-graph-container {
flex: 1 1 auto;
width: 100%;
min-height: 480px;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 14px;
background-color: #f8fafc;
background-image:
radial-gradient(circle at 1px 1px, rgba(148, 163, 184, 0.35) 1px, transparent 0);
background-size: 20px 20px;
position: relative;
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8), 0 1px 3px rgba(15, 23, 42, 0.04);
}
.project-fact-graph-container::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(ellipse at center, transparent 55%, rgba(241, 245, 249, 0.65) 100%);
z-index: 1;
}
.project-fact-graph-container .loading-spinner,
.project-fact-graph-container .project-fact-graph-empty,
.project-fact-graph-container .error-message {
position: relative;
z-index: 2;
}
.project-fact-graph-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
height: 100%;
min-height: 420px;
padding: 40px 32px;
}
.project-fact-graph-empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
margin-bottom: 18px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid #e2e8f0;
border-radius: 20px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
}
.project-fact-graph-empty-title {
margin: 0 0 8px;
font-size: 1.0625rem;
font-weight: 600;
color: #0f172a;
letter-spacing: -0.01em;
}
.project-fact-graph-empty-hint {
margin: 0 0 16px;
max-width: 420px;
font-size: 0.875rem;
line-height: 1.6;
color: #64748b;
}
.project-fact-graph-empty-steps {
margin: 0 0 20px;
padding-left: 1.2rem;
max-width: 400px;
text-align: left;
font-size: 0.8125rem;
line-height: 1.65;
color: #475569;
}
.project-fact-graph-empty-steps li {
margin-bottom: 4px;
}
.project-fact-graph-empty-steps li::marker {
color: #6366f1;
font-weight: 600;
}
.project-fact-graph-empty-cta {
margin-top: 4px;
}
.project-fact-graph-sidebar {
position: absolute;
top: 12px;
right: 12px;
bottom: 12px;
width: min(300px, calc(100% - 24px));
z-index: 12;
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 14px;
padding: 16px;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(15, 23, 42, 0.06);
animation: projectGraphSidebarIn 0.2s ease;
overflow-x: hidden;
overflow-y: auto;
}
.project-fact-graph-sidebar[hidden] {
display: none !important;
}
@keyframes projectGraphSidebarIn {
from {
opacity: 0;
transform: translateX(12px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.project-fact-graph-sidebar-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.project-fact-graph-sidebar-title-wrap {
min-width: 0;
flex: 1;
}
.project-fact-graph-sidebar-header h4 {
margin: 4px 0 0;
font-size: 0.9rem;
font-weight: 600;
word-break: break-all;
color: #0f172a;
line-height: 1.35;
}
.project-fact-graph-node-category {
display: inline-block;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
border: 1px solid #e2e8f0;
}
.project-fact-graph-node-category--target { color: #4338ca; background: #eef2ff; border-color: #c7d2fe; }
.project-fact-graph-node-category--finding { color: #be123c; background: #fff1f2; border-color: #fecdd3; }
.project-fact-graph-node-category--exploit,
.project-fact-graph-node-category--poc { color: #c2410c; background: #ffedd5; border-color: #fdba74; }
.project-fact-graph-node-category--auth { color: #0f766e; background: #f0fdfa; border-color: #99f6e4; }
.project-fact-graph-sidebar-close {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #64748b;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.project-fact-graph-sidebar-close:hover {
color: #0f172a;
border-color: #cbd5e1;
background: #f8fafc;
}
.project-fact-graph-node-meta {
margin: 0;
font-size: 0.8125rem;
line-height: 1.55;
color: #64748b;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-width: 0;
word-break: break-word;
overflow-wrap: anywhere;
}
.project-fact-graph-node-summary {
display: block;
width: 100%;
min-width: 0;
color: #475569;
}
.project-fact-graph-edges-wrap {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 4px;
border-top: 1px solid #f1f5f9;
}
.project-fact-graph-edges-title {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
color: #475569;
letter-spacing: 0.02em;
}
.project-fact-graph-edges-hint {
margin: 0;
font-size: 0.72rem;
line-height: 1.45;
color: #94a3b8;
word-break: break-word;
overflow-wrap: anywhere;
}
.project-fact-graph-edges-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.project-fact-graph-edges-empty {
margin: 0;
font-size: 0.8125rem;
color: #94a3b8;
}
.project-fact-graph-edge-item {
display: grid;
grid-template-columns: auto auto auto 1fr auto;
align-items: center;
gap: 4px 6px;
padding: 6px 8px;
font-size: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.project-fact-graph-edge-item:hover {
border-color: #cbd5e1;
background: #fff;
}
.project-fact-graph-edge-item.is-selected {
border-color: #818cf8;
background: #eef2ff;
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.25);
}
.project-fact-graph-edge-dir {
font-size: 0.6875rem;
font-weight: 600;
color: #64748b;
white-space: nowrap;
}
.project-fact-graph-edge-type {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.6875rem;
color: #4338ca;
white-space: nowrap;
}
.project-fact-graph-edge-arrow {
color: #94a3b8;
}
.project-fact-graph-edge-peer {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #334155;
min-width: 0;
}
.project-fact-graph-edge-delete {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: 1px solid #fecaca;
border-radius: 6px;
background: #fff;
color: #dc2626;
font-size: 1rem;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
.project-fact-graph-edge-delete:hover {
background: #fef2f2;
border-color: #f87171;
}
.project-fact-graph-edge-synthetic {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
color: #cbd5e1;
flex-shrink: 0;
}
.project-fact-graph-sidebar-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding-top: 4px;
border-top: 1px solid #f1f5f9;
}
.project-fact-graph-stats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin: 12px 0 0;
flex: 0 0 auto;
}
.projects-graph-stat-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.8125rem;
color: #64748b;
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 4px 12px;
border-radius: 999px;
}
.projects-graph-stat-badge strong {
font-size: 0.9375rem;
font-weight: 700;
color: #0f172a;
font-variant-numeric: tabular-nums;
}
#project-panel-graph .projects-fact-toolbar-filters {
flex-wrap: wrap;
}
.projects-fact-link-badge {
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
color: var(--text-secondary, #64748b);
}
.projects-fact-link-badge--empty {
opacity: 0.45;
}
@media (max-width: 1100px) {
.project-fact-graph-sidebar {
width: min(280px, calc(100% - 24px));
}
.projects-graph-actions {
margin-left: 0;
width: 100%;
justify-content: flex-end;
}
}
@media (max-width: 1400px) { @media (max-width: 1400px) {
.projects-detail-header { .projects-detail-header {
padding: 16px 18px 14px; padding: 16px 18px 14px;
@@ -24513,11 +25009,12 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
} }
#project-panel-facts .data-table--projects th:nth-child(3), #project-panel-facts .data-table--projects th:nth-child(3),
#project-panel-facts .data-table--projects td:nth-child(3) { #project-panel-facts .data-table--projects td:nth-child(3) {
width: 24%; width: 22%;
} }
#project-panel-facts .data-table--projects th:nth-child(7), #project-panel-facts .data-table--projects th.col-actions,
#project-panel-facts .data-table--projects td:nth-child(7) { #project-panel-facts .data-table--projects td.col-actions {
width: 23%; min-width: 188px;
max-width: 220px;
} }
} }
/* —— 项目设置:左右分栏 + 底部危险区,无内层滚动 —— */ /* —— 项目设置:左右分栏 + 底部危险区,无内层滚动 —— */
+48
View File
@@ -258,10 +258,58 @@
"vulnerabilityManagement": "Vulnerability management", "vulnerabilityManagement": "Vulnerability management",
"addFactCta": "+ Add fact", "addFactCta": "+ Add fact",
"tabFacts": "Fact board", "tabFacts": "Fact board",
"tabGraph": "Attack path",
"tabConversations": "Bound conversations", "tabConversations": "Bound conversations",
"tabVulns": "Related vulnerabilities", "tabVulns": "Related vulnerabilities",
"tabSettings": "Settings", "tabSettings": "Settings",
"factToolbarHint": "Index includes key and summary only (must include what + where + how to verify); put attack chain / POC in body, and reproduce via get_project_fact.", "factToolbarHint": "Index includes key and summary only (must include what + where + how to verify); put attack chain / POC in body, and reproduce via get_project_fact.",
"graphToolbarHint": "Attack path graph shows target → finding → exploit causality. Dashed edges are tentative. Click a node for details.",
"graphView": "View",
"graphViewPath": "Attack path",
"graphViewFull": "Full graph",
"graphSearchSr": "Search nodes",
"graphSearchPlaceholder": "Search nodes…",
"graphRefresh": "Refresh",
"graphCenter": "Center",
"graphEmpty": "No graph data yet. Add links on finding/exploit facts (discovered_on → target/*) to build the path.",
"graphEmptyTitle": "Build your attack path",
"graphEmptyStep1": "Add target facts (domains, endpoints, scope)",
"graphEmptyStep2": "Record findings/exploits with links between facts",
"graphEmptyStep3": "Use Connect mode or edit facts to add relationships",
"graphEmptyCta": "Add first fact",
"graphStats": "Nodes: {{nodes}} | Edges: {{edges}}",
"graphStatsNodes": "Nodes",
"graphStatsEdges": "Edges",
"graphLegendDiscovered": "discovered_on",
"graphLegendLeads": "leads_to",
"graphLegendExploits": "exploits",
"graphLegendTentative": "Tentative (dashed)",
"factLinksLabel": "Outgoing links",
"factLinksPlaceholder": "discovered_on: target/primary_domain\nleads_to: finding/swagger",
"factLinksHint": "One per line: type: fact_key. Common types: discovered_on, depends_on, leads_to, enables, exploits. Saving replaces all outgoing links.",
"linksColumn": "Links",
"linkCountsTitle": "Outgoing / incoming edge counts",
"graphConnect": "Connect",
"graphConnectActive": "Connecting…",
"graphConnectPickTarget": "Source {{source}} selected — click target node",
"graphEdgeTypePrompt": "Edge type (discovered_on / leads_to / depends_on / enables / exploits)",
"graphConnectFailed": "Failed to create edge",
"graphConnectSuccess": "Edge created",
"graphEdgesTitle": "Links",
"graphEdgesHint": "Click an edge in the graph to focus it. Delete mistaken links here.",
"graphEdgesEmpty": "No links yet",
"graphEdgeOutgoing": "Outgoing",
"graphEdgeIncoming": "Incoming",
"graphEdgeSynthetic": "Auto-generated from fact link; edit the fact to remove",
"confirmDeleteGraphEdge": "Delete this link?",
"graphEdgeDeleteFailed": "Failed to delete edge",
"graphEdgeDeleteSuccess": "Edge deleted",
"graphDeleteEdge": "Delete",
"promoteAttackChain": "Promote chain",
"promoteAttackChainTitle": "Promote conversation attack chain to project facts",
"confirmPromoteAttackChain": "Promote this conversation's attack chain into the project? Facts and edges will be created or updated.",
"promoteAttackChainFailed": "Promote failed",
"promoteAttackChainSuccess": "Promoted: {{facts_created}} new / {{facts_updated}} updated / {{edges_created}} edges",
"searchFactsSr": "Search facts", "searchFactsSr": "Search facts",
"searchFactsPlaceholder": "Search key, summary, body…", "searchFactsPlaceholder": "Search key, summary, body…",
"category": "Category", "category": "Category",
+48
View File
@@ -246,10 +246,58 @@
"vulnerabilityManagement": "漏洞管理", "vulnerabilityManagement": "漏洞管理",
"addFactCta": "+ 添加事实", "addFactCta": "+ 添加事实",
"tabFacts": "事实黑板", "tabFacts": "事实黑板",
"tabGraph": "攻击路径",
"tabConversations": "关联对话", "tabConversations": "关联对话",
"tabVulns": "关联漏洞", "tabVulns": "关联漏洞",
"tabSettings": "设置", "tabSettings": "设置",
"factToolbarHint": "索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 bodyAgent 通过 get_project_fact 复现", "factToolbarHint": "索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 bodyAgent 通过 get_project_fact 复现",
"graphToolbarHint": "攻击路径图:展示 target → finding → exploit 因果关系;虚线边表示待确认。点击节点查看详情。",
"graphView": "视图",
"graphViewPath": "攻击路径",
"graphViewFull": "完整关系",
"graphSearchSr": "搜索节点",
"graphSearchPlaceholder": "搜索节点…",
"graphRefresh": "刷新",
"graphCenter": "居中",
"graphEmpty": "暂无路径图数据。为 finding/exploit 类事实添加关系边(discovered_on → target/*)后将在此展示。",
"graphEmptyTitle": "构建攻击路径图",
"graphEmptyStep1": "添加 target 类事实(目标、域名、入口)",
"graphEmptyStep2": "记录 finding / exploit 并在 links 中连边",
"graphEmptyStep3": "使用「连边」模式或编辑事实手动补关系",
"graphEmptyCta": "添加第一条事实",
"graphStats": "节点: {{nodes}} | 边: {{edges}}",
"graphStatsNodes": "节点",
"graphStatsEdges": "边",
"graphLegendDiscovered": "discovered_on",
"graphLegendLeads": "leads_to",
"graphLegendExploits": "exploits",
"graphLegendTentative": "待确认(虚线)",
"factLinksLabel": "关系边(出边)",
"factLinksPlaceholder": "discovered_on: target/primary_domain\nleads_to: finding/swagger",
"factLinksHint": "每行一条:type: fact_key。常用 typediscovered_on、depends_on、leads_to、enables、exploits。保存时替换全部出边。",
"linksColumn": "关系",
"linkCountsTitle": "出边数 / 入边数",
"graphConnect": "连边",
"graphConnectActive": "连边中…",
"graphConnectPickTarget": "已选 {{source}},请点击目标节点",
"graphEdgeTypePrompt": "边类型(discovered_on / leads_to / depends_on / enables / exploits",
"graphConnectFailed": "创建边失败",
"graphConnectSuccess": "边已创建",
"graphEdgesTitle": "关系边",
"graphEdgesHint": "点击图中连线可定位;误连可在此删除。",
"graphEdgesEmpty": "暂无关系边",
"graphEdgeOutgoing": "出边",
"graphEdgeIncoming": "入边",
"graphEdgeSynthetic": "由事实关联自动生成,请编辑事实解除",
"confirmDeleteGraphEdge": "确定删除此关系边?",
"graphEdgeDeleteFailed": "删除边失败",
"graphEdgeDeleteSuccess": "边已删除",
"graphDeleteEdge": "删边",
"promoteAttackChain": "沉淀攻击链",
"promoteAttackChainTitle": "将对话攻击链沉淀为项目事实与边",
"confirmPromoteAttackChain": "将该对话的攻击链沉淀到本项目?会创建/更新事实与关系边。",
"promoteAttackChainFailed": "沉淀失败",
"promoteAttackChainSuccess": "已沉淀:新建 {{facts_created}} / 更新 {{facts_updated}} / 边 {{edges_created}}",
"searchFactsSr": "搜索事实", "searchFactsSr": "搜索事实",
"searchFactsPlaceholder": "搜索 key、摘要、body…", "searchFactsPlaceholder": "搜索 key、摘要、body…",
"category": "分类", "category": "分类",
+555
View File
@@ -0,0 +1,555 @@
/**
* 项目事实图渲染(Cytoscape + ELK),供项目管理页使用。
* 节点采用 SVG 卡片背景(图标 + 多行文字),避免 Cytoscape 原生 label 定位问题。
*/
(function (global) {
'use strict';
let _cy = null;
let _graphData = null;
let _onNodeSelect = null;
let _onEdgeSelect = null;
const EDGE_COLORS = {
discovered_on: '#4F46E5',
leads_to: '#64748B',
enables: '#E11D48',
exploits: '#DC2626',
depends_on: '#0D9488',
contains: '#6366F1',
part_of: '#6366F1',
supports: '#94A3B8',
links_vuln: '#BE123C',
};
const CARD_PAD = 14;
const CARD_TEXT_PAD_RIGHT = 12;
const CARD_ICON = 36;
const CARD_ICON_GAP = 12;
const CARD_TEXT_X = CARD_PAD + CARD_ICON + CARD_ICON_GAP;
const CARD_MIN_W = 300;
const CARD_TARGET_W = 360;
const CARD_MIN_H = 88;
const CARD_MAX_H = 152;
const CARD_HEADER_FS = 11;
const CARD_HEADER_LH = 16;
const CARD_SUMMARY_FS = 13;
const CARD_SUMMARY_LH = 18;
const CARD_SECTION_GAP = 6;
const CARD_FONT =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif';
function nodeTheme(type) {
switch (type) {
case 'target':
return { typeLabel: '目标', typeEn: 'TARGET', accent: '#4F46E5', bgEnd: '#F5F3FF', icon: 'target' };
case 'finding':
return { typeLabel: '发现', typeEn: 'FINDING', accent: '#E11D48', bgEnd: '#FFF1F2', icon: 'vulnerability' };
case 'vulnerability':
return { typeLabel: '漏洞', typeEn: 'VULN', accent: '#BE123C', bgEnd: '#FFF1F2', icon: 'vulnerability' };
case 'auth':
return { typeLabel: '认证', typeEn: 'AUTH', accent: '#0D9488', bgEnd: '#F0FDFA', icon: 'default' };
case 'infra':
return { typeLabel: '基础设施', typeEn: 'INFRA', accent: '#64748B', bgEnd: '#F8FAFC', icon: 'default' };
case 'missing':
return { typeLabel: '缺失', typeEn: 'MISSING', accent: '#CBD5E1', bgEnd: '#F1F5F9', icon: 'default' };
default:
return { typeLabel: '备注', typeEn: 'NOTE', accent: '#94A3B8', bgEnd: '#F8FAFC', icon: 'default' };
}
}
function escapeXml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function escapeHtml(str) {
return escapeXml(str);
}
function buildStatusBadge(confidence) {
const conf = (confidence || '').toLowerCase();
if (conf === 'tentative') return '待确认';
if (conf === 'deprecated') return '已废弃';
return '';
}
function buildHeaderText(theme, statusBadge) {
const line = (theme.typeEn || '') + ' · ' + (theme.typeLabel || '');
return statusBadge ? line + ' · ' + statusBadge : line;
}
function isWideChar(ch) {
const code = ch.codePointAt(0) || 0;
if (code >= 0x4e00 && code <= 0x9fff) return true;
if (code >= 0x3400 && code <= 0x4dbf) return true;
if (code >= 0xf900 && code <= 0xfaff) return true;
if (code >= 0xff00 && code <= 0xffef) return true;
return /[·:,。;!?【】()《》、「」]/.test(ch);
}
function charWidth(ch, fontSize, bold) {
const scale = bold ? 1.05 : 1;
if (ch === ' ') return fontSize * 0.3 * scale;
if (isWideChar(ch)) return fontSize * scale;
return fontSize * 0.58 * scale;
}
function lineWidth(text, fontSize, bold) {
let width = 0;
for (const ch of text) width += charWidth(ch, fontSize, bold);
return width;
}
function wrapTextLines(text, maxWidth, fontSize, maxLines, bold) {
const raw = String(text || '').replace(/\s+/g, ' ').trim();
if (!raw) return ['—'];
const safeWidth = Math.max(40, maxWidth - 4);
const chars = [...raw];
const lines = [];
let index = 0;
while (index < chars.length && lines.length < maxLines) {
let line = '';
let width = 0;
while (index < chars.length) {
const ch = chars[index];
const nextWidth = charWidth(ch, fontSize, bold);
if (line && width + nextWidth > safeWidth) break;
line += ch;
width += nextWidth;
index += 1;
if (width >= safeWidth) break;
}
if (line) lines.push(line);
}
if (index < chars.length && lines.length) {
let last = lines[lines.length - 1];
while (last.length > 1 && lineWidth(last + '…', fontSize, bold) > safeWidth) {
last = last.slice(0, -1);
}
lines[lines.length - 1] = last + '…';
}
return lines.length ? lines : ['—'];
}
function cardTextWidth(nodeWidth) {
return nodeWidth - CARD_TEXT_X - CARD_PAD - CARD_TEXT_PAD_RIGHT;
}
function computeNodeLayout(type, summary, statusBadge, theme) {
const width = type === 'target' ? CARD_TARGET_W : CARD_MIN_W;
const textW = cardTextWidth(width);
const t = theme || nodeTheme(type);
const headerLines = wrapTextLines(buildHeaderText(t, statusBadge), textW, CARD_HEADER_FS, 2, true);
const summaryLines = wrapTextLines(summary, textW, CARD_SUMMARY_FS, 4, true);
const height = Math.min(
CARD_MAX_H,
Math.max(
CARD_MIN_H,
CARD_PAD +
headerLines.length * CARD_HEADER_LH +
CARD_SECTION_GAP +
summaryLines.length * CARD_SUMMARY_LH +
CARD_PAD,
),
);
return {
width,
height,
headerLines,
summaryLines,
searchLabel: headerLines.join(' ') + '\n' + summaryLines.join(' '),
};
}
function svgIconGroup(kind, color, x, y) {
const scale = (CARD_ICON / 24).toFixed(3);
if (kind === 'target') {
return (
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
`<circle cx="12" cy="12" r="6" fill="none" stroke="${color}" stroke-width="2"/>` +
`<circle cx="12" cy="12" r="2.5" fill="${color}"/></g>`
);
}
if (kind === 'vulnerability') {
return (
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
`<path d="M12 3l9 16H3z" fill="none" stroke="${color}" stroke-width="2"/>` +
`<line x1="12" y1="9" x2="12" y2="13" stroke="${color}" stroke-width="2"/>` +
`<circle cx="12" cy="16" r="1" fill="${color}"/></g>`
);
}
return (
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
`<circle cx="12" cy="12" r="5" fill="${color}" opacity="0.85"/></g>`
);
}
function buildNodeCardSvgUrl(theme, layout, confidence) {
const { width, height, headerLines, summaryLines } = layout;
const accent = theme.accent;
const bgEnd = theme.bgEnd;
const conf = (confidence || '').toLowerCase();
const isTentative = conf === 'tentative';
const isDeprecated = conf === 'deprecated';
const iconX = CARD_PAD;
const iconY = (height - CARD_ICON) / 2;
const headerY = CARD_PAD + CARD_HEADER_FS;
const summaryY = CARD_PAD + headerLines.length * CARD_HEADER_LH + CARD_SECTION_GAP + CARD_SUMMARY_FS;
const stroke = isTentative
? `stroke="${accent}" stroke-width="1.5" stroke-dasharray="8 5" stroke-opacity="0.9"`
: `stroke="${accent}" stroke-width="1.5" stroke-opacity="0.72"`;
const headerSvg = headerLines
.map(
(line, i) =>
`<text x="${CARD_TEXT_X}" y="${headerY + i * CARD_HEADER_LH}" font-size="${CARD_HEADER_FS}" font-weight="700" fill="${accent}" fill-opacity="0.88" font-family='${CARD_FONT}'>${escapeXml(line)}</text>`,
)
.join('');
const summarySvg = summaryLines
.map(
(line, i) =>
`<text x="${CARD_TEXT_X}" y="${summaryY + i * CARD_SUMMARY_LH}" font-size="${CARD_SUMMARY_FS}" font-weight="600" fill="#0f172a" font-family='${CARD_FONT}'>${escapeXml(line)}</text>`,
)
.join('');
const textClipW = width - CARD_TEXT_X - CARD_PAD - 2;
const textClipH = height - CARD_PAD * 2 + 4;
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
`<defs><linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">` +
`<stop offset="0%" stop-color="#FFFFFF"/><stop offset="100%" stop-color="${bgEnd}"/></linearGradient>` +
`<clipPath id="textClip"><rect x="${CARD_TEXT_X}" y="${CARD_PAD - 2}" width="${textClipW}" height="${textClipH}"/></clipPath></defs>` +
`<g${isDeprecated ? ' opacity="0.55"' : ''}>` +
`<rect x="0.75" y="0.75" width="${width - 1.5}" height="${height - 1.5}" rx="12" fill="url(#bg)" ${stroke}/>` +
svgIconGroup(theme.icon, accent, iconX, iconY) +
`<g clip-path="url(#textClip)">${headerSvg}${summarySvg}</g>` +
`</g></svg>`;
try {
return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
} catch (e) {
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
}
}
function destroy() {
if (_cy) {
_cy.destroy();
_cy = null;
}
_graphData = null;
}
function centerGraph() {
if (!_cy) return;
try {
_cy.fit(undefined, 56);
if (_cy.zoom() < 0.65) {
_cy.zoom(0.65);
_cy.center();
}
} catch (e) {
console.warn('centerGraph', e);
}
}
function applyElkLayout(validEdges, isComplex) {
const layoutOptions = {
name: 'breadthfirst',
directed: true,
spacingFactor: isComplex ? 3.0 : 2.5,
padding: 40,
};
const elkInstance = typeof ELK !== 'undefined' ? new ELK() : null;
if (!elkInstance) {
const layout = _cy.layout(layoutOptions);
layout.one('layoutstop', () => setTimeout(centerGraph, 100));
layout.run();
return;
}
const nodeGap = isComplex ? 45 : 60;
const layerGap = isComplex ? 70 : 95;
const elkGraph = {
id: 'root',
layoutOptions: {
'elk.algorithm': 'layered',
'elk.direction': 'DOWN',
'elk.spacing.nodeNode': String(nodeGap),
'elk.layered.spacing.nodeNodeBetweenLayers': String(layerGap),
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
},
children: (_graphData.nodes || []).map((node) => {
const n = _cy ? _cy.getElementById(node.id) : null;
const w = n.length ? n.data('nodeWidth') : node.type === 'target' ? CARD_TARGET_W : CARD_MIN_W;
const h = n.length ? n.data('nodeHeight') : CARD_MIN_H;
return { id: node.id, width: w, height: h };
}),
edges: validEdges.map((edge) => ({
id: edge.id,
sources: [edge.source],
targets: [edge.target],
})),
};
elkInstance
.layout(elkGraph)
.then((laidOut) => {
(laidOut.children || []).forEach((elkNode) => {
const cyNode = _cy.getElementById(elkNode.id);
if (cyNode.length && elkNode.x != null) {
cyNode.position({
x: elkNode.x + (elkNode.width || 0) / 2,
y: elkNode.y + (elkNode.height || 0) / 2,
});
}
});
setTimeout(centerGraph, 120);
})
.catch(() => {
const layout = _cy.layout(layoutOptions);
layout.one('layoutstop', () => setTimeout(centerGraph, 100));
layout.run();
});
}
function render(container, graphData, options) {
if (!container || typeof cytoscape === 'undefined') {
if (container) {
container.innerHTML = '<div class="error-message">Cytoscape 未加载</div>';
}
return null;
}
destroy();
_graphData = graphData || { nodes: [], edges: [] };
_onNodeSelect = options && options.onNodeSelect;
_onEdgeSelect = options && options.onEdgeSelect;
const nodes = _graphData.nodes || [];
const edges = _graphData.edges || [];
if (!nodes.length) {
const title = (options && options.emptyTitle) || '';
const hint = (options && options.emptyText) || '暂无事实关系';
const steps = (options && options.emptySteps) || [];
const actionLabel = options && options.emptyActionLabel;
const stepsHtml = steps.length
? '<ol class="project-fact-graph-empty-steps">' +
steps.map((s) => '<li>' + escapeHtml(String(s)) + '</li>').join('') +
'</ol>'
: '';
const actionHtml =
actionLabel && options.onEmptyAction
? '<button type="button" class="btn-primary btn-small project-fact-graph-empty-cta">' +
escapeHtml(actionLabel) +
'</button>'
: '';
container.innerHTML =
'<div class="project-fact-graph-empty">' +
'<div class="project-fact-graph-empty-icon" aria-hidden="true">' +
'<svg width="48" height="48" viewBox="0 0 24 24" fill="none"><circle cx="6" cy="6" r="2.5" fill="#4F46E5" opacity="0.9"/><circle cx="18" cy="6" r="2.5" fill="#E11D48" opacity="0.9"/><circle cx="12" cy="18" r="2.5" fill="#0D9488" opacity="0.9"/>' +
'<path d="M8 7l4 9M16 7l-4 9M8 7h8" stroke="#CBD5E1" stroke-width="1.5" stroke-linecap="round"/></svg>' +
'</div>' +
(title ? '<h4 class="project-fact-graph-empty-title">' + escapeHtml(title) + '</h4>' : '') +
'<p class="project-fact-graph-empty-hint">' + escapeHtml(hint) + '</p>' +
stepsHtml +
actionHtml +
'</div>';
const cta = container.querySelector('.project-fact-graph-empty-cta');
if (cta && typeof options.onEmptyAction === 'function') {
cta.addEventListener('click', options.onEmptyAction);
}
return null;
}
container.innerHTML = '';
const isComplex = nodes.length > 15 || edges.length > 25;
const elements = [];
const nodeIds = new Set();
nodes.forEach((node) => {
nodeIds.add(node.id);
const theme = nodeTheme(node.type || node.category || 'note');
const label = node.label || node.fact_key || node.id;
const statusBadge = buildStatusBadge(node.confidence);
const layout = computeNodeLayout(node.type || node.category || 'note', label, statusBadge, theme);
elements.push({
data: {
id: node.id,
label: layout.searchLabel,
factKey: node.fact_key || node.id,
type: node.type || 'note',
typeLabel: theme.typeLabel,
typeEn: theme.typeEn,
accentColor: theme.accent,
statusBadge: statusBadge,
confidence: node.confidence || '',
nodeWidth: layout.width,
nodeHeight: layout.height,
cardSvgUrl: buildNodeCardSvgUrl(theme, layout, node.confidence),
},
});
});
const validEdges = [];
edges.forEach((edge, idx) => {
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) return;
const id = edge.id || 'e-' + idx;
validEdges.push({ ...edge, id });
elements.push({
data: {
id,
source: edge.source,
target: edge.target,
type: edge.type || 'leads_to',
confidence: edge.confidence || 'confirmed',
},
});
});
_cy = cytoscape({
container,
elements,
style: [
{
selector: 'node',
style: {
label: '',
width: (ele) => ele.data('nodeWidth') || CARD_MIN_W,
height: (ele) => ele.data('nodeHeight') || CARD_MIN_H,
shape: 'round-rectangle',
'background-color': '#ffffff',
'background-image': (ele) => ele.data('cardSvgUrl') || 'none',
'background-width': (ele) => (ele.data('nodeWidth') || CARD_MIN_W) + 'px',
'background-height': (ele) => (ele.data('nodeHeight') || CARD_MIN_H) + 'px',
'background-position-x': '50%',
'background-position-y': '50%',
'background-fit': 'none',
'border-width': 0,
'background-opacity': 1,
},
},
{
selector: 'edge',
style: {
width: 2.2,
'line-color': (ele) => EDGE_COLORS[ele.data('type')] || '#CBD5E1',
'target-arrow-color': (ele) => EDGE_COLORS[ele.data('type')] || '#CBD5E1',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
opacity: (ele) => (ele.data('confidence') === 'tentative' ? 0.55 : 0.9),
'line-style': (ele) => (ele.data('confidence') === 'tentative' ? 'dashed' : 'solid'),
},
},
{
selector: 'edge:selected',
style: {
width: 3.5,
opacity: 1,
'line-color': '#4F46E5',
'target-arrow-color': '#4F46E5',
},
},
{
selector: 'node:selected',
style: {
'border-width': 3,
'border-color': '#4F46E5',
'border-opacity': 1,
},
},
],
minZoom: 0.35,
maxZoom: 3,
});
_cy.on('tap', 'node', (evt) => {
const d = evt.target.data();
const key = d.factKey || d.id;
if (_connectMode && _connectPick) {
_connectPick(key);
return;
}
if (typeof _onNodeSelect === 'function') {
_onNodeSelect(key, d);
}
});
_cy.on('tap', 'edge', (evt) => {
if (_connectMode && _connectPick) return;
const d = evt.target.data();
if (typeof _onEdgeSelect === 'function') {
_onEdgeSelect(d.id, d);
}
});
_cy.on('tap', (evt) => {
if (evt.target === _cy) {
clearEdgeSelection();
}
});
applyElkLayout(validEdges, isComplex);
return _cy;
}
function filterBySearch(query) {
if (!_cy) return;
const q = (query || '').trim().toLowerCase();
_cy.nodes().forEach((n) => {
if (!q) {
n.style('opacity', 1);
return;
}
const text = (
(n.data('label') || '') +
' ' +
(n.data('factKey') || '') +
' ' +
(n.data('typeLabel') || '')
).toLowerCase();
n.style('opacity', text.includes(q) ? 1 : 0.15);
});
_cy.edges().forEach((e) => {
e.style('opacity', q ? 0.12 : 0.9);
});
}
let _connectMode = false;
let _connectPick = null;
function selectEdge(edgeId) {
if (!_cy || !edgeId) return;
_cy.elements().unselect();
const edge = _cy.getElementById(edgeId);
if (edge.length) edge.select();
}
function clearEdgeSelection() {
if (!_cy) return;
_cy.elements().unselect();
}
function setConnectMode(enabled, onPick) {
_connectMode = !!enabled;
_connectPick = typeof onPick === 'function' ? onPick : null;
if (_cy) {
_cy.userPanningEnabled(!_connectMode);
}
}
global.ProjectFactGraph = {
render,
destroy,
center: centerGraph,
filterBySearch,
setConnectMode,
selectEdge,
clearEdgeSelection,
};
})(typeof window !== 'undefined' ? window : globalThis);
+317 -5
View File
@@ -64,6 +64,8 @@ Host: ...
## 关联 ## 关联
- related_vulnerability_id: <可选> - related_vulnerability_id: <可选>
- 依赖事实: <fact_key,如 auth/session_cookie> - 依赖事实: <fact_key,如 auth/session_cookie>
- 结构化出边(自动同步):
- discovered_on: target/primary_domain
## 备注与不确定性 ## 备注与不确定性
<待验证假设、环境差异、绕过尝试记录>`; <待验证假设、环境差异、绕过尝试记录>`;
@@ -730,20 +732,280 @@ async function selectProject(id) {
function switchProjectTab(tab) { function switchProjectTab(tab) {
currentProjectTab = tab; currentProjectTab = tab;
['facts', 'conversations', 'vulns', 'settings'].forEach((t) => { ['facts', 'graph', 'conversations', 'vulns', 'settings'].forEach((t) => {
const btn = document.getElementById(`project-tab-${t}`); const btn = document.getElementById(`project-tab-${t}`);
const panel = document.getElementById(`project-panel-${t}`); const panel = document.getElementById(`project-panel-${t}`);
if (btn) btn.classList.toggle('is-active', t === tab); if (btn) btn.classList.toggle('is-active', t === tab);
if (panel) panel.hidden = t !== tab; if (panel) panel.hidden = t !== tab;
}); });
if (tab === 'facts') loadProjectFacts(); if (tab === 'facts') loadProjectFacts();
if (tab === 'graph') loadProjectFactGraph();
if (tab === 'conversations') loadProjectConversations(); if (tab === 'conversations') loadProjectConversations();
if (tab === 'vulns') loadProjectVulnerabilities(); if (tab === 'vulns') loadProjectVulnerabilities();
} }
let _selectedGraphFactKey = null;
let _selectedGraphEdgeId = null;
let _currentGraphData = null;
let _graphConnectMode = false;
let _graphConnectSource = null;
function toggleProjectFactGraphConnectMode() {
_graphConnectMode = !_graphConnectMode;
_graphConnectSource = null;
const btn = document.getElementById('project-graph-connect-btn');
if (btn) {
btn.classList.toggle('is-active', _graphConnectMode);
btn.textContent = _graphConnectMode ? tp('projects.graphConnectActive') : tp('projects.graphConnect');
btn.classList.toggle('projects-graph-action-btn--connect-active', _graphConnectMode);
}
if (typeof ProjectFactGraph !== 'undefined') {
ProjectFactGraph.setConnectMode(_graphConnectMode, handleGraphConnectNodePick);
}
}
async function handleGraphConnectNodePick(factKey) {
if (!factKey || String(factKey).startsWith('vuln:')) return;
if (!_graphConnectSource) {
_graphConnectSource = factKey;
if (typeof showNotification === 'function') {
showNotification(tpFmt('projects.graphConnectPickTarget', `已选源节点 ${factKey},请点击目标节点`, { source: factKey }), 'info');
}
return;
}
if (_graphConnectSource === factKey) return;
const edgeType = window.prompt(tp('projects.graphEdgeTypePrompt'), 'leads_to');
if (!edgeType) {
_graphConnectSource = null;
return;
}
const res = await apiFetch(`/api/projects/${currentProjectId}/fact-edges`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_fact_key: _graphConnectSource,
target_fact_key: factKey,
edge_type: edgeType.trim(),
}),
});
_graphConnectSource = null;
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return alert(err.error || tp('projects.graphConnectFailed'));
}
if (typeof showNotification === 'function') showNotification(tp('projects.graphConnectSuccess'), 'success');
loadProjectFactGraph();
loadProjectFacts();
}
function formatOutgoingLinksForModal(links) {
if (!links || !links.length) return '';
return links
.map((e) => `${e.edge_type || e.type}: ${e.target_fact_key || e.to}`)
.join('\n');
}
async function loadProjectFactGraph() {
const container = document.getElementById('project-fact-graph-container');
const statsEl = document.getElementById('project-fact-graph-stats');
if (!container || !currentProjectId) return;
container.innerHTML = `<div class="loading-spinner">${escapeHtml(tp('common.loading'))}</div>`;
closeProjectFactGraphSidebar();
const view = document.getElementById('project-graph-view')?.value || 'path';
const hideDeprecated = document.getElementById('project-facts-filter-hide-deprecated')?.checked !== false;
const params = new URLSearchParams({ view });
if (!hideDeprecated) params.set('exclude_deprecated', '0');
try {
const res = await apiFetch(`/api/projects/${currentProjectId}/fact-graph?${params}`);
if (!res.ok) throw new Error(tp('common.loadFailed'));
const data = await res.json();
_currentGraphData = data;
if (typeof ProjectFactGraph !== 'undefined') {
ProjectFactGraph.render(container, data, {
emptyText: tp('projects.graphEmpty'),
emptyTitle: tp('projects.graphEmptyTitle'),
emptySteps: [
tp('projects.graphEmptyStep1'),
tp('projects.graphEmptyStep2'),
tp('projects.graphEmptyStep3'),
],
emptyActionLabel: tp('projects.graphEmptyCta'),
onEmptyAction: () => showAddFactModal(),
onNodeSelect: (factKey) => showProjectFactGraphNode(factKey, _currentGraphData),
onEdgeSelect: (edgeId) => showProjectFactGraphEdge(edgeId, _currentGraphData),
});
}
const nodeCount = (data.nodes || []).length;
const edgeCount = (data.edges || []).length;
if (statsEl) {
statsEl.innerHTML =
`<span class="projects-graph-stat-badge"><strong>${nodeCount}</strong> ${escapeHtml(tp('projects.graphStatsNodes'))}</span>` +
`<span class="projects-graph-stat-badge"><strong>${edgeCount}</strong> ${escapeHtml(tp('projects.graphStatsEdges'))}</span>`;
}
} catch (e) {
container.innerHTML = `<div class="error-message">${escapeHtml(e.message || tp('common.loadFailed'))}</div>`;
if (statsEl) statsEl.textContent = '';
}
}
function filterProjectFactGraph() {
const q = document.getElementById('project-graph-search')?.value || '';
if (typeof ProjectFactGraph !== 'undefined') {
ProjectFactGraph.filterBySearch(q);
}
}
function centerProjectFactGraph() {
if (typeof ProjectFactGraph !== 'undefined') ProjectFactGraph.center();
}
function closeProjectFactGraphSidebar() {
_selectedGraphFactKey = null;
_selectedGraphEdgeId = null;
if (typeof ProjectFactGraph !== 'undefined') ProjectFactGraph.clearEdgeSelection();
const sidebar = document.getElementById('project-fact-graph-sidebar');
if (sidebar) sidebar.hidden = true;
}
function isSyntheticGraphEdge(edge) {
if (!edge) return true;
const id = String(edge.id || '');
const type = String(edge.type || '');
return id.startsWith('vuln-link:') || type === 'links_vuln';
}
function getGraphEdgesForFact(factKey, graphData) {
if (!factKey || !graphData?.edges) return [];
return graphData.edges.filter((e) => e.source === factKey || e.target === factKey);
}
function renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId) {
const edges = getGraphEdgesForFact(factKey, graphData);
if (!edges.length) {
return `<p class="project-fact-graph-edges-empty">${escapeHtml(tp('projects.graphEdgesEmpty'))}</p>`;
}
return edges
.map((e) => {
const isOut = e.source === factKey;
const dirLabel = isOut ? tp('projects.graphEdgeOutgoing') : tp('projects.graphEdgeIncoming');
const other = isOut ? e.target : e.source;
const arrow = isOut ? '→' : '←';
const selected = e.id === selectedEdgeId ? ' is-selected' : '';
const synthetic = isSyntheticGraphEdge(e);
const deleteBtn = synthetic
? `<span class="project-fact-graph-edge-synthetic" title="${escapeHtml(tp('projects.graphEdgeSynthetic'))}">—</span>`
: `<button type="button" class="project-fact-graph-edge-delete" data-edge-id="${escapeHtml(e.id)}" onclick="event.stopPropagation(); deleteProjectFactEdge(this.dataset.edgeId)" title="${escapeHtml(tp('projects.graphDeleteEdge'))}">×</button>`;
return `<div class="project-fact-graph-edge-item${selected}" data-edge-id="${escapeHtml(e.id)}" onclick="focusProjectFactGraphEdge(${JSON.stringify(e.id)})">
<span class="project-fact-graph-edge-dir">${escapeHtml(dirLabel)}</span>
<span class="project-fact-graph-edge-type">${escapeHtml(e.type || '')}</span>
<span class="project-fact-graph-edge-arrow">${arrow}</span>
<span class="project-fact-graph-edge-peer" title="${escapeHtml(other)}">${escapeHtml(other)}</span>
${deleteBtn}
</div>`;
})
.join('');
}
function renderProjectFactGraphEdges(factKey, graphData, selectedEdgeId) {
const wrap = document.getElementById('project-fact-graph-edges-wrap');
const list = document.getElementById('project-fact-graph-edges-list');
if (!wrap || !list) return;
const edges = getGraphEdgesForFact(factKey, graphData);
wrap.hidden = false;
list.innerHTML = renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId);
if (selectedEdgeId) {
const selectedEl = list.querySelector('[data-edge-id="' + String(selectedEdgeId).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]');
if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
}
if (!edges.length) wrap.hidden = false;
}
function showProjectFactGraphNode(factKey, graphData, selectedEdgeId) {
if (!factKey || String(factKey).startsWith('vuln:')) {
closeProjectFactGraphSidebar();
return;
}
_selectedGraphFactKey = factKey;
_selectedGraphEdgeId = selectedEdgeId || null;
const node = (graphData?.nodes || []).find((n) => n.fact_key === factKey || n.id === factKey);
const sidebar = document.getElementById('project-fact-graph-sidebar');
const titleEl = document.getElementById('project-fact-graph-node-title');
const metaEl = document.getElementById('project-fact-graph-node-meta');
const categoryEl = document.getElementById('project-fact-graph-node-category');
if (!sidebar || !titleEl || !metaEl) return;
titleEl.textContent = factKey;
if (categoryEl) {
const cat = node?.category || node?.type || '';
categoryEl.textContent = cat;
categoryEl.hidden = !cat;
categoryEl.className = 'project-fact-graph-node-category project-fact-graph-node-category--' + (cat || 'note');
}
const conf = node?.confidence || '';
const label = node?.label || '';
if (label || conf) {
const parts = [];
if (label) {
parts.push(`<span class="project-fact-graph-node-summary">${escapeHtml(label)}</span>`);
}
if (conf) {
parts.push(formatConfidenceBadge(conf));
}
metaEl.innerHTML = parts.join('');
} else {
metaEl.textContent = '';
}
renderProjectFactGraphEdges(factKey, graphData, _selectedGraphEdgeId);
if (_selectedGraphEdgeId && typeof ProjectFactGraph !== 'undefined') {
ProjectFactGraph.selectEdge(_selectedGraphEdgeId);
} else if (typeof ProjectFactGraph !== 'undefined') {
ProjectFactGraph.clearEdgeSelection();
}
sidebar.hidden = false;
}
function showProjectFactGraphEdge(edgeId, graphData) {
const edge = (graphData?.edges || []).find((e) => e.id === edgeId);
if (!edge) return;
const anchorKey = edge.source && !String(edge.source).startsWith('vuln:') ? edge.source : edge.target;
showProjectFactGraphNode(anchorKey, graphData, edgeId);
}
function focusProjectFactGraphEdge(edgeId) {
if (!edgeId || !_currentGraphData) return;
showProjectFactGraphEdge(edgeId, _currentGraphData);
}
async function deleteProjectFactEdge(edgeId) {
if (!edgeId || !currentProjectId) return;
const edge = (_currentGraphData?.edges || []).find((e) => e.id === edgeId);
if (isSyntheticGraphEdge(edge)) return;
if (!confirm(tp('projects.confirmDeleteGraphEdge'))) return;
const res = await apiFetch(`/api/projects/${currentProjectId}/fact-edges/${encodeURIComponent(edgeId)}`, {
method: 'DELETE',
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return alert(err.error || tp('projects.graphEdgeDeleteFailed'));
}
if (typeof showNotification === 'function') showNotification(tp('projects.graphEdgeDeleteSuccess'), 'success');
const keepKey = _selectedGraphFactKey;
await loadProjectFactGraph();
if (keepKey) showProjectFactGraphNode(keepKey, _currentGraphData);
loadProjectFacts();
}
function openSelectedGraphFactDetail() {
if (_selectedGraphFactKey) viewProjectFactBody(_selectedGraphFactKey);
}
function editSelectedGraphFact() {
if (_selectedGraphFactKey) showEditFactModal(_selectedGraphFactKey);
}
function buildProjectFactsQueryParams() { function buildProjectFactsQueryParams() {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('limit', '200'); params.set('limit', '200');
params.set('include_link_counts', 'true');
const search = document.getElementById('project-facts-search')?.value?.trim(); const search = document.getElementById('project-facts-search')?.value?.trim();
const category = document.getElementById('project-facts-filter-category')?.value?.trim(); const category = document.getElementById('project-facts-filter-category')?.value?.trim();
const confidence = document.getElementById('project-facts-filter-confidence')?.value?.trim(); const confidence = document.getElementById('project-facts-filter-confidence')?.value?.trim();
@@ -768,11 +1030,11 @@ function debouncedLoadProjectFacts() {
async function loadProjectFacts() { async function loadProjectFacts() {
const tbody = document.getElementById('project-facts-tbody'); const tbody = document.getElementById('project-facts-tbody');
if (!tbody || !currentProjectId) return; if (!tbody || !currentProjectId) return;
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tp('common.loading'))}</td></tr>`; tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${escapeHtml(tp('common.loading'))}</td></tr>`;
const qs = buildProjectFactsQueryParams().toString(); const qs = buildProjectFactsQueryParams().toString();
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${qs}`); const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${qs}`);
if (!res.ok) { if (!res.ok) {
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tp('common.loadFailed'))}</td></tr>`; tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
return; return;
} }
const facts = await res.json(); const facts = await res.json();
@@ -782,7 +1044,7 @@ async function loadProjectFacts() {
document.getElementById('project-facts-filter-category')?.value || document.getElementById('project-facts-filter-category')?.value ||
document.getElementById('project-facts-filter-confidence')?.value || document.getElementById('project-facts-filter-confidence')?.value ||
document.getElementById('project-facts-filter-sparse')?.checked; document.getElementById('project-facts-filter-sparse')?.checked;
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${ tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${
hasFilter ? tp('projects.noMatchingFacts') : tp('projects.noFacts') hasFilter ? tp('projects.noMatchingFacts') : tp('projects.noFacts')
}</td></tr>`; }</td></tr>`;
refreshProjectHeaderStats(); refreshProjectHeaderStats();
@@ -797,10 +1059,16 @@ async function loadProjectFacts() {
const pinBadge = f.pinned const pinBadge = f.pinned
? `<span class="projects-list-item-badge" title="${escapeHtml(tp('projects.pinned'))}">${escapeHtml(tp('projects.pinned'))}</span>` ? `<span class="projects-list-item-badge" title="${escapeHtml(tp('projects.pinned'))}">${escapeHtml(tp('projects.pinned'))}</span>`
: ''; : '';
const lc = f.link_counts || {};
const linkBadge =
lc.outgoing || lc.incoming
? `<span class="projects-fact-link-badge" title="${escapeHtml(tp('projects.linkCountsTitle'))}">↑${lc.outgoing || 0}${lc.incoming || 0}</span>`
: '<span class="projects-fact-link-badge projects-fact-link-badge--empty">—</span>';
return `<tr> return `<tr>
<td class="cell-fact-key"><code class="projects-fact-key-chip" title="${keyEsc}">${keyEsc}</code>${pinBadge}${vulnLink}</td> <td class="cell-fact-key"><code class="projects-fact-key-chip" title="${keyEsc}">${keyEsc}</code>${pinBadge}${vulnLink}</td>
<td class="cell-fact-category">${formatCategoryBadge(f.category)}</td> <td class="cell-fact-category">${formatCategoryBadge(f.category)}</td>
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td> <td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
<td class="cell-fact-links">${linkBadge}</td>
<td>${formatFactBodyBadge(f)}</td> <td>${formatFactBodyBadge(f)}</td>
<td>${formatConfidenceBadge(f.confidence)}</td> <td>${formatConfidenceBadge(f.confidence)}</td>
<td>${formatProjectTime(f.updated_at, f.created_at)}</td> <td>${formatProjectTime(f.updated_at, f.created_at)}</td>
@@ -849,6 +1117,7 @@ async function loadProjectConversations() {
<td class="col-actions"> <td class="col-actions">
<div class="projects-table-actions"> <div class="projects-table-actions">
<button type="button" class="projects-action-btn projects-action-btn--view" data-conv-id="${idEsc}" onclick="openProjectConversation(this.dataset.convId)">${escapeHtml(tp('projects.open'))}</button> <button type="button" class="projects-action-btn projects-action-btn--view" data-conv-id="${idEsc}" onclick="openProjectConversation(this.dataset.convId)">${escapeHtml(tp('projects.open'))}</button>
<button type="button" class="projects-action-btn" data-conv-id="${idEsc}" onclick="promoteConversationAttackChain(this.dataset.convId)" title="${escapeHtml(tp('projects.promoteAttackChainTitle'))}">${escapeHtml(tp('projects.promoteAttackChain'))}</button>
<button type="button" class="projects-action-btn projects-action-btn--mute" data-conv-id="${idEsc}" onclick="unbindConversationFromProject(this.dataset.convId)" title="${escapeHtml(tp('projects.unbindProjectTitle'))}">${escapeHtml(tp('projects.unbind'))}</button> <button type="button" class="projects-action-btn projects-action-btn--mute" data-conv-id="${idEsc}" onclick="unbindConversationFromProject(this.dataset.convId)" title="${escapeHtml(tp('projects.unbindProjectTitle'))}">${escapeHtml(tp('projects.unbind'))}</button>
</div> </div>
</td> </td>
@@ -869,6 +1138,32 @@ function openProjectConversation(conversationId) {
}, 200); }, 200);
} }
async function promoteConversationAttackChain(conversationId) {
if (!currentProjectId || !conversationId) return;
if (!confirm(tp('projects.confirmPromoteAttackChain'))) return;
const res = await apiFetch(
`/api/projects/${currentProjectId}/promote-attack-chain/${encodeURIComponent(conversationId)}`,
{ method: 'POST' },
);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return alert(err.error || tp('projects.promoteAttackChainFailed'));
}
const data = await res.json();
if (typeof showNotification === 'function') {
showNotification(
tpFmt(
'projects.promoteAttackChainSuccess',
`已沉淀 ${data.facts_created || 0} 新 / ${data.facts_updated || 0} 更新 / ${data.edges_created || 0}`,
data,
),
'success',
);
}
loadProjectFacts();
if (currentProjectTab === 'graph') loadProjectFactGraph();
}
async function unbindConversationFromProject(conversationId) { async function unbindConversationFromProject(conversationId) {
if (!conversationId || !confirm(tp('projects.confirmUnbindConversation'))) return; if (!conversationId || !confirm(tp('projects.confirmUnbindConversation'))) return;
const res = await apiFetch(`/api/conversations/${encodeURIComponent(conversationId)}/project`, { const res = await apiFetch(`/api/conversations/${encodeURIComponent(conversationId)}/project`, {
@@ -1509,6 +1804,8 @@ function resetFactModalForm() {
if (pinEl) pinEl.checked = false; if (pinEl) pinEl.checked = false;
const rel = document.getElementById('fact-modal-related-vuln'); const rel = document.getElementById('fact-modal-related-vuln');
if (rel) rel.value = ''; if (rel) rel.value = '';
const linksEl = document.getElementById('fact-modal-links');
if (linksEl) linksEl.value = '';
updateFactFormHints(); updateFactFormHints();
} }
@@ -1540,6 +1837,8 @@ function fillFactModalForm(f) {
} }
const rel = document.getElementById('fact-modal-related-vuln'); const rel = document.getElementById('fact-modal-related-vuln');
if (rel) rel.value = f.related_vulnerability_id || ''; if (rel) rel.value = f.related_vulnerability_id || '';
const linksEl = document.getElementById('fact-modal-links');
if (linksEl) linksEl.value = formatOutgoingLinksForModal(f.outgoing_links);
const pinEl = document.getElementById('fact-modal-pinned'); const pinEl = document.getElementById('fact-modal-pinned');
if (pinEl) pinEl.checked = !!f.pinned; if (pinEl) pinEl.checked = !!f.pinned;
updateFactFormHints(); updateFactFormHints();
@@ -1556,7 +1855,7 @@ async function showEditFactModal(factKey) {
resetFactModalForm(); resetFactModalForm();
openProjectsOverlay('fact-modal', { focus: false }); openProjectsOverlay('fact-modal', { focus: false });
const res = await apiFetch( const res = await apiFetch(
`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`, `/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}&include_links=true`,
); );
if (!res.ok) { if (!res.ok) {
closeFactModal(); closeFactModal();
@@ -1594,6 +1893,7 @@ async function saveFactModal() {
confidence: document.getElementById('fact-modal-confidence').value, confidence: document.getElementById('fact-modal-confidence').value,
pinned: !!document.getElementById('fact-modal-pinned')?.checked, pinned: !!document.getElementById('fact-modal-pinned')?.checked,
related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '', related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '',
links_text: document.getElementById('fact-modal-links')?.value || '',
}; };
const editId = window._factModalEditId; const editId = window._factModalEditId;
const res = editId const res = editId
@@ -1613,12 +1913,14 @@ async function saveFactModal() {
} }
closeFactModal(); closeFactModal();
loadProjectFacts(); loadProjectFacts();
if (currentProjectTab === 'graph') loadProjectFactGraph();
} }
async function deleteProjectFact(id) { async function deleteProjectFact(id) {
if (!confirm(tp('projects.confirmDeleteFact'))) return; if (!confirm(tp('projects.confirmDeleteFact'))) return;
await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' }); await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' });
loadProjectFacts(); loadProjectFacts();
if (currentProjectTab === 'graph') loadProjectFactGraph();
} }
function parseProjectDate(t) { function parseProjectDate(t) {
@@ -1974,5 +2276,15 @@ window.viewFactsForVulnerability = viewFactsForVulnerability;
window.openProjectConversation = openProjectConversation; window.openProjectConversation = openProjectConversation;
window.unbindConversationFromProject = unbindConversationFromProject; window.unbindConversationFromProject = unbindConversationFromProject;
window.loadProjectConversations = loadProjectConversations; window.loadProjectConversations = loadProjectConversations;
window.loadProjectFactGraph = loadProjectFactGraph;
window.filterProjectFactGraph = filterProjectFactGraph;
window.centerProjectFactGraph = centerProjectFactGraph;
window.closeProjectFactGraphSidebar = closeProjectFactGraphSidebar;
window.openSelectedGraphFactDetail = openSelectedGraphFactDetail;
window.editSelectedGraphFact = editSelectedGraphFact;
window.promoteConversationAttackChain = promoteConversationAttackChain;
window.deleteProjectFactEdge = deleteProjectFactEdge;
window.focusProjectFactGraphEdge = focusProjectFactGraphEdge;
window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode;
window.rebuildProjectNameMap = rebuildProjectNameMap; window.rebuildProjectNameMap = rebuildProjectNameMap;
window.projectNameById = projectNameById; window.projectNameById = projectNameById;
+85 -1
View File
@@ -1498,6 +1498,13 @@
</aside> </aside>
<main class="projects-detail" id="projects-detail-main"> <main class="projects-detail" id="projects-detail-main">
<div class="projects-detail-placeholder" id="projects-detail-placeholder"> <div class="projects-detail-placeholder" id="projects-detail-placeholder">
<div class="projects-placeholder-icon" aria-hidden="true">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="4" width="18" height="16" rx="3" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 9h18M8 4V9M16 4V9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M8 14h8M8 17h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</div>
<h3 data-i18n="projects.selectOrCreateTitle">选择或创建项目</h3> <h3 data-i18n="projects.selectOrCreateTitle">选择或创建项目</h3>
<p data-i18n="projects.selectOrCreateHint">项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。</p> <p data-i18n="projects.selectOrCreateHint">项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。</p>
<button class="btn-primary" type="button" onclick="showNewProjectModal()" data-i18n="projects.createFirstProject">创建第一个项目</button> <button class="btn-primary" type="button" onclick="showNewProjectModal()" data-i18n="projects.createFirstProject">创建第一个项目</button>
@@ -1527,6 +1534,7 @@
</header> </header>
<nav class="projects-tabs" role="tablist"> <nav class="projects-tabs" role="tablist">
<button type="button" id="project-tab-facts" class="projects-tab is-active" role="tab" onclick="switchProjectTab('facts')" data-i18n="projects.tabFacts">事实黑板</button> <button type="button" id="project-tab-facts" class="projects-tab is-active" role="tab" onclick="switchProjectTab('facts')" data-i18n="projects.tabFacts">事实黑板</button>
<button type="button" id="project-tab-graph" class="projects-tab" role="tab" onclick="switchProjectTab('graph')" data-i18n="projects.tabGraph">攻击路径</button>
<button type="button" id="project-tab-conversations" class="projects-tab" role="tab" onclick="switchProjectTab('conversations')" data-i18n="projects.tabConversations">关联对话</button> <button type="button" id="project-tab-conversations" class="projects-tab" role="tab" onclick="switchProjectTab('conversations')" data-i18n="projects.tabConversations">关联对话</button>
<button type="button" id="project-tab-vulns" class="projects-tab" role="tab" onclick="switchProjectTab('vulns')" data-i18n="projects.tabVulns">关联漏洞</button> <button type="button" id="project-tab-vulns" class="projects-tab" role="tab" onclick="switchProjectTab('vulns')" data-i18n="projects.tabVulns">关联漏洞</button>
<button type="button" id="project-tab-settings" class="projects-tab" role="tab" onclick="switchProjectTab('settings')" data-i18n="projects.tabSettings">设置</button> <button type="button" id="project-tab-settings" class="projects-tab" role="tab" onclick="switchProjectTab('settings')" data-i18n="projects.tabSettings">设置</button>
@@ -1587,11 +1595,81 @@
</div> </div>
<div class="projects-table-wrap"> <div class="projects-table-wrap">
<table class="data-table data-table--projects"> <table class="data-table data-table--projects">
<thead><tr><th>Key</th><th data-i18n="projects.category">分类</th><th data-i18n="projects.summary">摘要</th><th>Body</th><th data-i18n="projects.confidence">置信度</th><th data-i18n="projects.updated">更新</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead> <thead><tr><th>Key</th><th data-i18n="projects.category">分类</th><th data-i18n="projects.summary">摘要</th><th data-i18n="projects.linksColumn">关系</th><th>Body</th><th data-i18n="projects.confidence">置信度</th><th data-i18n="projects.updated">更新</th><th class="col-actions" data-i18n="common.actions">操作</th></tr></thead>
<tbody id="project-facts-tbody"></tbody> <tbody id="project-facts-tbody"></tbody>
</table> </table>
</div> </div>
</div> </div>
<div id="project-panel-graph" class="projects-panel projects-panel--graph" role="tabpanel" hidden>
<div class="projects-fact-toolbar projects-graph-toolbar">
<p class="projects-fact-toolbar-hint" role="note">
<svg class="projects-fact-toolbar-hint-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/>
<path d="M12 10v6M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<span data-i18n="projects.graphToolbarHint">攻击路径图:展示 target → finding → exploit 因果关系;虚线边表示待确认。点击节点查看详情。</span>
</p>
<div class="projects-fact-toolbar-filters projects-graph-toolbar-row">
<label class="projects-fact-filter-field">
<span class="projects-fact-filter-label" data-i18n="projects.graphView">视图</span>
<select id="project-graph-view" onchange="loadProjectFactGraph()">
<option value="path" data-i18n="projects.graphViewPath">攻击路径</option>
<option value="full" data-i18n="projects.graphViewFull">完整关系</option>
</select>
</label>
<label class="projects-fact-filter-field projects-fact-filter-field--search projects-graph-search-field">
<span class="sr-only" data-i18n="projects.graphSearchSr">搜索节点</span>
<svg class="projects-fact-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
<path d="M20 20L16 16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<input type="search" id="project-graph-search" placeholder="搜索节点…" oninput="filterProjectFactGraph()" autocomplete="off" data-i18n="projects.graphSearchPlaceholder" data-i18n-attr="placeholder">
</label>
<div class="projects-graph-actions" role="group" aria-label="Graph actions">
<button type="button" class="projects-graph-action-btn" onclick="loadProjectFactGraph()" title="刷新" data-i18n="projects.graphRefresh" data-i18n-attr="title">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M21 3v6h-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span data-i18n="projects.graphRefresh">刷新</span>
</button>
<button type="button" class="projects-graph-action-btn" onclick="centerProjectFactGraph()" title="居中" data-i18n="projects.graphCenter" data-i18n-attr="title">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-i18n="projects.graphCenter">居中</span>
</button>
<button type="button" class="projects-graph-action-btn projects-graph-action-btn--connect" id="project-graph-connect-btn" onclick="toggleProjectFactGraphConnectMode()" data-i18n="projects.graphConnect">连边</button>
</div>
</div>
<div class="projects-graph-legend" aria-hidden="true">
<span class="projects-graph-legend-item"><i style="--legend-color:#4F46E5"></i><span data-i18n="projects.graphLegendDiscovered">discovered_on</span></span>
<span class="projects-graph-legend-item"><i style="--legend-color:#64748B"></i><span data-i18n="projects.graphLegendLeads">leads_to</span></span>
<span class="projects-graph-legend-item"><i style="--legend-color:#DC2626"></i><span data-i18n="projects.graphLegendExploits">exploits</span></span>
<span class="projects-graph-legend-item projects-graph-legend-item--dashed"><i style="--legend-color:#94A3B8"></i><span data-i18n="projects.graphLegendTentative">待确认</span></span>
</div>
</div>
<div class="project-fact-graph-layout">
<div id="project-fact-graph-container" class="project-fact-graph-container"></div>
<aside id="project-fact-graph-sidebar" class="project-fact-graph-sidebar" hidden>
<div class="project-fact-graph-sidebar-header">
<div class="project-fact-graph-sidebar-title-wrap">
<span id="project-fact-graph-node-category" class="project-fact-graph-node-category"></span>
<h4 id="project-fact-graph-node-title"></h4>
</div>
<button type="button" class="project-fact-graph-sidebar-close" onclick="closeProjectFactGraphSidebar()" aria-label="关闭" data-i18n="common.close" data-i18n-attr="aria-label">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
<p id="project-fact-graph-node-meta" class="project-fact-graph-node-meta"></p>
<div id="project-fact-graph-edges-wrap" class="project-fact-graph-edges-wrap" hidden>
<h5 class="project-fact-graph-edges-title" data-i18n="projects.graphEdgesTitle">关系边</h5>
<p class="project-fact-graph-edges-hint" data-i18n="projects.graphEdgesHint">点击图中连线可定位;误连可在此删除。</p>
<div id="project-fact-graph-edges-list" class="project-fact-graph-edges-list"></div>
</div>
<div class="project-fact-graph-sidebar-actions">
<button type="button" class="btn-primary btn-small" id="project-fact-graph-detail-btn" onclick="openSelectedGraphFactDetail()" data-i18n="projects.details">详情</button>
<button type="button" class="btn-secondary btn-small" id="project-fact-graph-edit-btn" onclick="editSelectedGraphFact()" data-i18n="common.edit">编辑</button>
</div>
</aside>
</div>
<div id="project-fact-graph-stats" class="project-fact-graph-stats"></div>
</div>
<div id="project-panel-conversations" class="projects-panel" role="tabpanel" hidden> <div id="project-panel-conversations" class="projects-panel" role="tabpanel" hidden>
<div class="projects-panel-toolbar projects-panel-toolbar--hint"> <div class="projects-panel-toolbar projects-panel-toolbar--hint">
<p class="projects-fact-toolbar-hint" role="note"> <p class="projects-fact-toolbar-hint" role="note">
@@ -4339,6 +4417,11 @@
<label for="fact-modal-related-vuln" data-i18n="projects.relatedVulnIdLabel">关联漏洞 ID</label> <label for="fact-modal-related-vuln" data-i18n="projects.relatedVulnIdLabel">关联漏洞 ID</label>
<input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选" data-i18n="projects.optional" data-i18n-attr="placeholder"> <input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选" data-i18n="projects.optional" data-i18n-attr="placeholder">
</div> </div>
<div class="form-group">
<label for="fact-modal-links" data-i18n="projects.factLinksLabel">关系边(出边)</label>
<textarea id="fact-modal-links" class="form-input" rows="4" placeholder="discovered_on: target/primary_domain&#10;leads_to: finding/swagger" data-i18n="projects.factLinksPlaceholder" data-i18n-attr="placeholder"></textarea>
<p class="projects-field-hint" data-i18n="projects.factLinksHint">每行一条:type: fact_key。常用 typediscovered_on、depends_on、leads_to、enables、exploits。保存时替换全部出边。</p>
</div>
</div> </div>
<div class="projects-modal-footer"> <div class="projects-modal-footer">
<button class="btn-secondary" type="button" onclick="closeFactModal()" data-i18n="common.cancel">取消</button> <button class="btn-secondary" type="button" onclick="closeFactModal()" data-i18n="common.cancel">取消</button>
@@ -4396,6 +4479,7 @@
<script src="/static/js/terminal.js"></script> <script src="/static/js/terminal.js"></script>
<script src="/static/js/knowledge.js"></script> <script src="/static/js/knowledge.js"></script>
<script src="/static/js/skills.js"></script> <script src="/static/js/skills.js"></script>
<script src="/static/js/fact-graph.js"></script>
<script src="/static/js/projects.js"></script> <script src="/static/js/projects.js"></script>
<script src="/static/js/vulnerability.js?v=12"></script> <script src="/static/js/vulnerability.js?v=12"></script>
<script src="/static/js/webshell.js"></script> <script src="/static/js/webshell.js"></script>