mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-28 10:11:37 +02:00
Add files via upload
This commit is contained in:
+328
-61
@@ -20996,6 +20996,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
}
|
||||
#page-projects .page-content.projects-page-layout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
min-height: calc(100vh - 128px);
|
||||
padding: 16px 20px 24px;
|
||||
@@ -21004,6 +21005,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
.projects-sidebar-card {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
@@ -21011,7 +21013,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
max-height: calc(100vh - 160px);
|
||||
min-height: 420px;
|
||||
}
|
||||
.projects-sidebar-head {
|
||||
display: flex;
|
||||
@@ -21118,6 +21120,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
.projects-detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 420px;
|
||||
@@ -21173,6 +21176,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
min-height: 420px;
|
||||
align-self: stretch;
|
||||
}
|
||||
.projects-detail-header {
|
||||
display: flex;
|
||||
@@ -21244,6 +21248,11 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.projects-stat-chip--warn {
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
border-color: #fcd34d;
|
||||
}
|
||||
.projects-detail-header-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
@@ -21283,6 +21292,18 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
padding: 16px 24px 24px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.projects-panel--settings {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
background: #fff;
|
||||
}
|
||||
/* display:flex 会覆盖 [hidden] 默认 display:none,非激活 Tab 会叠在事实黑板下方 */
|
||||
.projects-panel[hidden] {
|
||||
@@ -21300,6 +21321,185 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 10px;
|
||||
}
|
||||
/* —— 事实黑板:说明 + 筛选工具栏 —— */
|
||||
.projects-fact-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.projects-fact-toolbar-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: #475569;
|
||||
background: #f0f7ff;
|
||||
border: 1px solid #dbeafe;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.projects-fact-toolbar-hint-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.projects-fact-toolbar-hint strong {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
.projects-fact-toolbar-hint code {
|
||||
padding: 1px 5px;
|
||||
font-size: 0.75rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
color: #1d4ed8;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.projects-fact-toolbar-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.projects-fact-filter-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.projects-fact-filter-field--search {
|
||||
flex: 1 1 200px;
|
||||
min-width: 160px;
|
||||
max-width: 360px;
|
||||
position: relative;
|
||||
}
|
||||
.projects-fact-filter-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.projects-fact-filter-field input,
|
||||
.projects-fact-filter-field select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
font-size: 0.8125rem;
|
||||
color: #0f172a;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.projects-fact-filter-field--search input {
|
||||
padding-left: 34px;
|
||||
background: #fff;
|
||||
}
|
||||
.projects-fact-search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 9px;
|
||||
color: #94a3b8;
|
||||
pointer-events: none;
|
||||
}
|
||||
.projects-fact-filter-field input:focus,
|
||||
.projects-fact-filter-field select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12);
|
||||
background: #fff;
|
||||
}
|
||||
.projects-fact-filter-field select {
|
||||
min-width: 108px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.projects-fact-filter-toggles {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
.projects-fact-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.projects-fact-toggle input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.projects-fact-toggle span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.projects-fact-toggle:hover span {
|
||||
border-color: #cbd5e1;
|
||||
color: #475569;
|
||||
}
|
||||
.projects-fact-toggle input:focus-visible + span {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.projects-fact-toggle input:checked + span {
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
border-color: #93c5fd;
|
||||
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.projects-fact-filter-field--search {
|
||||
flex: 1 1 100%;
|
||||
max-width: none;
|
||||
}
|
||||
.projects-fact-filter-toggles {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
.projects-filter-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.8125rem;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.projects-pin-toggle {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.projects-panel-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: #64748b;
|
||||
@@ -21374,33 +21574,28 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
}
|
||||
.projects-panel--settings {
|
||||
padding: 0;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* —— 项目设置:左右分栏 + 底部危险区,无内层滚动 —— */
|
||||
.projects-settings-layout {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
}
|
||||
.projects-settings-intro {
|
||||
padding: 18px 24px 14px;
|
||||
flex-shrink: 0;
|
||||
padding: 14px 20px 12px;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.8);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.projects-settings-intro-title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.projects-settings-intro-hint {
|
||||
margin: 4px 0 0;
|
||||
@@ -21410,35 +21605,37 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
}
|
||||
.projects-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
|
||||
gap: 18px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
|
||||
grid-template-rows: minmax(min-content, 1fr);
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
padding: 20px 24px;
|
||||
padding: 14px 20px;
|
||||
flex: 1 1 auto;
|
||||
min-height: min-content;
|
||||
}
|
||||
.projects-settings-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 4px 12px rgba(15, 23, 42, 0.03);
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.projects-settings-card:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.06), 0 8px 20px rgba(15, 23, 42, 0.04);
|
||||
.projects-settings-card--basic {
|
||||
min-height: min-content;
|
||||
}
|
||||
.projects-settings-card--scope {
|
||||
min-height: 300px;
|
||||
min-height: 0;
|
||||
}
|
||||
.projects-settings-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
background: #fafbfc;
|
||||
}
|
||||
@@ -21476,16 +21673,14 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
margin: 3px 0 0;
|
||||
}
|
||||
.projects-settings-card-body {
|
||||
padding: 18px 20px 20px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 14px 16px 16px;
|
||||
}
|
||||
.projects-settings-card-body--fill {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
.projects-scope-toolbar {
|
||||
display: flex;
|
||||
@@ -21505,25 +21700,29 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
color: #0f172a;
|
||||
}
|
||||
.projects-scope-editor {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-height: 120px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #1e293b;
|
||||
background: #0f172a;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.projects-scope-textarea {
|
||||
flex: 1;
|
||||
min-height: 180px;
|
||||
.projects-panel--settings .projects-scope-textarea {
|
||||
flex: 1 1 auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 132px;
|
||||
max-height: none;
|
||||
resize: vertical;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
background: #0f172a !important;
|
||||
color: #e2e8f0 !important;
|
||||
padding: 14px 16px !important;
|
||||
padding: 12px 14px !important;
|
||||
font-size: 0.8125rem !important;
|
||||
line-height: 1.6 !important;
|
||||
box-shadow: none !important;
|
||||
@@ -21561,32 +21760,36 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.projects-settings-card--danger {
|
||||
flex-shrink: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin: 0 24px 16px;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #fffbfb 0%, #fff 50%);
|
||||
height: auto;
|
||||
margin: 0 20px 16px;
|
||||
padding: 18px 20px;
|
||||
background: linear-gradient(135deg, #fffbfb 0%, #fff 55%);
|
||||
border-color: #fecaca;
|
||||
box-shadow: 0 1px 2px rgba(220, 38, 38, 0.06);
|
||||
}
|
||||
.projects-settings-card--danger:hover {
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
.projects-settings-danger-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
}
|
||||
.projects-settings-card--danger .projects-settings-icon--red {
|
||||
margin-top: 1px;
|
||||
}
|
||||
.projects-settings-card--danger .projects-settings-card-title {
|
||||
margin: 0 0 4px;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.projects-settings-card--danger .projects-settings-card-hint {
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
}
|
||||
.projects-settings-card-title {
|
||||
font-size: 0.9375rem;
|
||||
@@ -21602,22 +21805,28 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
.projects-settings-danger-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.projects-settings-danger-actions .btn-small {
|
||||
min-height: 34px;
|
||||
padding: 7px 14px;
|
||||
}
|
||||
.projects-settings-footer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 24px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
border-top: 1px solid #eef2f7;
|
||||
background: #fff;
|
||||
border-radius: 0 0 14px 14px;
|
||||
box-shadow: 0 -1px 0 rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
.projects-settings-footer-hint {
|
||||
font-size: 0.8125rem;
|
||||
@@ -21651,33 +21860,35 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
@media (max-width: 960px) {
|
||||
.projects-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
grid-template-rows: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.projects-settings-card--scope {
|
||||
min-height: 0;
|
||||
}
|
||||
.projects-form-row--2 {
|
||||
grid-template-columns: 1fr;
|
||||
.projects-settings-card {
|
||||
height: auto;
|
||||
}
|
||||
.projects-settings-card--danger {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin: 0 16px 12px;
|
||||
margin: 0 16px 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.projects-settings-danger-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.projects-form-row--2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.projects-settings-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 14px 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.projects-settings-footer .btn-primary {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
.projects-settings-intro {
|
||||
padding: 14px 16px 12px;
|
||||
padding: 12px 16px 10px;
|
||||
}
|
||||
}
|
||||
.projects-form-field {
|
||||
@@ -21969,6 +22180,56 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
body.projects-modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
.fact-detail-prev-wrap {
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px dashed #e2e8f0;
|
||||
}
|
||||
.fact-detail-prev-title,
|
||||
.fact-detail-current-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.fact-detail-body--muted {
|
||||
opacity: 0.85;
|
||||
max-height: 200px;
|
||||
}
|
||||
.projects-modal-footer--split {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.projects-modal-footer-left,
|
||||
.projects-modal-footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.vulnerability-related-facts {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.vulnerability-related-facts ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.vulnerability-related-facts li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.vulnerability-related-facts a {
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.fact-detail-body {
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
@@ -22051,6 +22312,12 @@ body.projects-modal-open {
|
||||
color: #64748b;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.projects-fact-vuln-link {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 0.6875rem;
|
||||
color: #7c3aed;
|
||||
}
|
||||
.vulnerability-filter-field--project select {
|
||||
min-width: 120px;
|
||||
max-width: 160px;
|
||||
|
||||
@@ -2912,6 +2912,9 @@ async function startNewConversation() {
|
||||
window.currentConversationId = '';
|
||||
} catch (e) { /* ignore */ }
|
||||
currentConversationGroupId = null; // 新对话不属于任何分组
|
||||
if (typeof ensureDefaultActiveProjectForNewChat === 'function') {
|
||||
ensureDefaultActiveProjectForNewChat().catch(() => {});
|
||||
}
|
||||
if (typeof refreshChatProjectSelector === 'function') {
|
||||
refreshChatProjectSelector();
|
||||
}
|
||||
|
||||
+43
-34
@@ -2068,69 +2068,78 @@ function showToastNotification(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-notification toast-${type}`;
|
||||
|
||||
// 根据类型设置颜色
|
||||
const typeStyles = {
|
||||
success: {
|
||||
background: '#28a745',
|
||||
color: '#fff',
|
||||
icon: '✅'
|
||||
background: '#f4fbf6',
|
||||
border: '1px solid #cce8d4',
|
||||
color: '#3d6654',
|
||||
iconColor: '#52a06a',
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/><path d="M5 8l2 2 4-4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>'
|
||||
},
|
||||
error: {
|
||||
background: '#dc3545',
|
||||
color: '#fff',
|
||||
icon: '❌'
|
||||
background: '#fef7f7',
|
||||
border: '1px solid #f3d0d0',
|
||||
color: '#8b4444',
|
||||
iconColor: '#c96a6a',
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/><path d="M6 6l4 4M10 6l-4 4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>'
|
||||
},
|
||||
info: {
|
||||
background: '#17a2b8',
|
||||
color: '#fff',
|
||||
icon: 'ℹ️'
|
||||
background: '#f5f9ff',
|
||||
border: '1px solid #cfe0f5',
|
||||
color: '#4a6078',
|
||||
iconColor: '#6b8fbf',
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2"/><path d="M8 7v4M8 5.5v.01" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>'
|
||||
},
|
||||
warning: {
|
||||
background: '#ffc107',
|
||||
color: '#000',
|
||||
icon: '⚠️'
|
||||
background: '#fffbf3',
|
||||
border: '1px solid #f0dfc0',
|
||||
color: '#7a6535',
|
||||
iconColor: '#c4a04a',
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 2.5l6 10.5H2L8 2.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><path d="M8 7v2.5M8 11v.01" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const style = typeStyles[type] || typeStyles.info;
|
||||
|
||||
|
||||
toast.style.cssText = `
|
||||
background: ${style.background};
|
||||
border: ${style.border};
|
||||
color: ${style.color};
|
||||
padding: 14px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||
min-width: 220px;
|
||||
max-width: 420px;
|
||||
pointer-events: auto;
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
animation: slideInRight 0.25s ease-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
gap: 10px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
word-wrap: break-word;
|
||||
backdrop-filter: blur(8px);
|
||||
`;
|
||||
|
||||
|
||||
toast.innerHTML = `
|
||||
<span style="font-size: 1.2em; flex-shrink: 0;">${style.icon}</span>
|
||||
<span style="color: ${style.iconColor}; flex-shrink: 0; display: flex; align-items: center;">${style.icon}</span>
|
||||
<span style="flex: 1;">${escapeHtml(message)}</span>
|
||||
<button onclick="this.parentElement.remove()" style="
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${style.color};
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
font-size: 1rem;
|
||||
padding: 0;
|
||||
margin-left: 8px;
|
||||
opacity: 0.7;
|
||||
margin-left: 4px;
|
||||
opacity: 0.45;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.7'">×</button>
|
||||
" onmouseover="this.style.opacity='0.75'" onmouseout="this.style.opacity='0.45'">×</button>
|
||||
`;
|
||||
|
||||
container.appendChild(toast);
|
||||
@@ -2156,7 +2165,7 @@ if (!document.getElementById('toast-notification-styles')) {
|
||||
style.textContent = `
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
transform: translateX(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
@@ -2170,7 +2179,7 @@ if (!document.getElementById('toast-notification-styles')) {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
transform: translateX(16px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
+322
-24
@@ -187,6 +187,25 @@ function prefetchProjectsForChat() {
|
||||
ensureProjectsLoaded().catch(() => {});
|
||||
}
|
||||
|
||||
/** 新对话时:保留有效 activeProjectId,否则默认选中第一个进行中的项目 */
|
||||
async function ensureDefaultActiveProjectForNewChat() {
|
||||
try {
|
||||
await ensureProjectsLoaded();
|
||||
const cur = getActiveProjectId();
|
||||
if (cur && isActiveChatProjectId(cur)) return cur;
|
||||
const first =
|
||||
projectsCache.find((p) => p.pinned && p.status !== 'archived') ||
|
||||
projectsCache.find((p) => p.status !== 'archived');
|
||||
if (first) {
|
||||
setActiveProjectId(first.id);
|
||||
return first.id;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getProjectName(id) {
|
||||
return projectNameById[id] || id || '';
|
||||
}
|
||||
@@ -357,15 +376,39 @@ function updateProjectStatusPill(status) {
|
||||
el.className = 'projects-status-pill ' + (archived ? 'projects-status-pill--archived' : 'projects-status-pill--active');
|
||||
}
|
||||
|
||||
function updateProjectStats(factCount, vulnCount) {
|
||||
function updateProjectStats(stats) {
|
||||
const s = stats || {};
|
||||
const f = document.getElementById('project-stat-facts');
|
||||
const v = document.getElementById('project-stat-vulns');
|
||||
if (f) f.textContent = `${factCount ?? 0} 条事实`;
|
||||
if (v) v.textContent = `${vulnCount ?? 0} 个漏洞`;
|
||||
const c = document.getElementById('project-stat-conversations');
|
||||
const sparse = document.getElementById('project-stat-sparse');
|
||||
const fc = s.fact_count ?? s.factCount ?? 0;
|
||||
const vc = s.vuln_count ?? s.vulnCount ?? 0;
|
||||
const cc = s.conversation_count ?? s.conversationCount ?? 0;
|
||||
const sc = s.sparse_fact_count ?? s.sparseFactCount ?? 0;
|
||||
if (f) f.textContent = `${fc} 条事实`;
|
||||
if (v) v.textContent = `${vc} 个漏洞`;
|
||||
if (c) c.textContent = `${cc} 个对话`;
|
||||
if (sparse) {
|
||||
if (sc > 0) {
|
||||
sparse.hidden = false;
|
||||
sparse.textContent = `${sc} 待补全`;
|
||||
} else {
|
||||
sparse.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function selectProject(id) {
|
||||
currentProjectId = id;
|
||||
const searchEl = document.getElementById('project-facts-search');
|
||||
const catEl = document.getElementById('project-facts-filter-category');
|
||||
const confEl = document.getElementById('project-facts-filter-confidence');
|
||||
const sparseEl = document.getElementById('project-facts-filter-sparse');
|
||||
if (searchEl) searchEl.value = '';
|
||||
if (catEl) catEl.value = '';
|
||||
if (confEl) confEl.value = '';
|
||||
if (sparseEl) sparseEl.checked = false;
|
||||
renderProjectsSidebar();
|
||||
updateProjectsDetailVisibility();
|
||||
try {
|
||||
@@ -379,6 +422,8 @@ async function selectProject(id) {
|
||||
document.getElementById('project-edit-scope').value = p.scope_json || '';
|
||||
const statusEl = document.getElementById('project-edit-status');
|
||||
if (statusEl) statusEl.value = p.status || 'active';
|
||||
const pinEl = document.getElementById('project-edit-pinned');
|
||||
if (pinEl) pinEl.checked = !!p.pinned;
|
||||
updateProjectStatusPill(p.status || 'active');
|
||||
const metaEl = document.getElementById('projects-detail-meta');
|
||||
if (metaEl) metaEl.textContent = `更新于 ${formatProjectTime(p.updated_at)}`;
|
||||
@@ -397,42 +442,78 @@ async function selectProject(id) {
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
refreshProjectHeaderStats();
|
||||
await refreshProjectHeaderStats();
|
||||
switchProjectTab(currentProjectTab);
|
||||
}
|
||||
|
||||
function switchProjectTab(tab) {
|
||||
currentProjectTab = tab;
|
||||
['facts', 'vulns', 'settings'].forEach((t) => {
|
||||
['facts', 'conversations', 'vulns', 'settings'].forEach((t) => {
|
||||
const btn = document.getElementById(`project-tab-${t}`);
|
||||
const panel = document.getElementById(`project-panel-${t}`);
|
||||
if (btn) btn.classList.toggle('is-active', t === tab);
|
||||
if (panel) panel.hidden = t !== tab;
|
||||
});
|
||||
if (tab === 'facts') loadProjectFacts();
|
||||
if (tab === 'conversations') loadProjectConversations();
|
||||
if (tab === 'vulns') loadProjectVulnerabilities();
|
||||
}
|
||||
|
||||
function buildProjectFactsQueryParams() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '200');
|
||||
const search = document.getElementById('project-facts-search')?.value?.trim();
|
||||
const category = document.getElementById('project-facts-filter-category')?.value?.trim();
|
||||
const confidence = document.getElementById('project-facts-filter-confidence')?.value?.trim();
|
||||
const sparseOnly = document.getElementById('project-facts-filter-sparse')?.checked;
|
||||
const hideDeprecated = document.getElementById('project-facts-filter-hide-deprecated')?.checked;
|
||||
if (search) params.set('search', search);
|
||||
if (category) params.set('category', category);
|
||||
if (confidence) params.set('confidence', confidence);
|
||||
if (sparseOnly) params.set('sparse_only', 'true');
|
||||
if (hideDeprecated) params.set('exclude_deprecated', 'true');
|
||||
return params;
|
||||
}
|
||||
|
||||
function debouncedLoadProjectFacts() {
|
||||
if (_projectFactsFilterDebounce) clearTimeout(_projectFactsFilterDebounce);
|
||||
_projectFactsFilterDebounce = setTimeout(() => {
|
||||
_projectFactsFilterDebounce = null;
|
||||
loadProjectFacts();
|
||||
}, 280);
|
||||
}
|
||||
|
||||
async function loadProjectFacts() {
|
||||
const tbody = document.getElementById('project-facts-tbody');
|
||||
if (!tbody || !currentProjectId) return;
|
||||
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="7">加载中…</td></tr>';
|
||||
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?limit=200`);
|
||||
const qs = buildProjectFactsQueryParams().toString();
|
||||
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${qs}`);
|
||||
if (!res.ok) {
|
||||
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="7">加载失败</td></tr>';
|
||||
return;
|
||||
}
|
||||
const facts = await res.json();
|
||||
if (!facts.length) {
|
||||
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="7">暂无事实,点击「添加事实」或由 Agent 自动写入</td></tr>';
|
||||
const hasFilter =
|
||||
document.getElementById('project-facts-search')?.value?.trim() ||
|
||||
document.getElementById('project-facts-filter-category')?.value ||
|
||||
document.getElementById('project-facts-filter-confidence')?.value ||
|
||||
document.getElementById('project-facts-filter-sparse')?.checked;
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${
|
||||
hasFilter ? '无匹配事实,请调整筛选条件' : '暂无事实,点击「添加事实」或由 Agent 自动写入'
|
||||
}</td></tr>`;
|
||||
refreshProjectHeaderStats();
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = facts.map((f) => {
|
||||
const keyEsc = escapeHtml(f.fact_key);
|
||||
const idEsc = escapeHtml(f.id);
|
||||
const vulnLink = f.related_vulnerability_id
|
||||
? `<span class="projects-fact-vuln-link" title="关联漏洞 ID">${escapeHtml(f.related_vulnerability_id.slice(0, 8))}…</span>`
|
||||
: '';
|
||||
return `<tr>
|
||||
<td><code>${keyEsc}</code></td>
|
||||
<td><code>${keyEsc}</code>${vulnLink}</td>
|
||||
<td>${formatCategoryBadge(f.category)}</td>
|
||||
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
|
||||
<td>${formatFactBodyBadge(f)}</td>
|
||||
@@ -447,34 +528,85 @@ async function loadProjectFacts() {
|
||||
async function refreshProjectHeaderStats() {
|
||||
if (!currentProjectId) return;
|
||||
try {
|
||||
const [factsRes, vulnRes] = await Promise.all([
|
||||
apiFetch(`/api/projects/${currentProjectId}/facts?limit=500`),
|
||||
apiFetch(`/api/vulnerabilities?project_id=${encodeURIComponent(currentProjectId)}&limit=100`),
|
||||
]);
|
||||
let fc = 0;
|
||||
let vc = 0;
|
||||
if (factsRes.ok) {
|
||||
const f = await factsRes.json();
|
||||
fc = Array.isArray(f) ? f.length : 0;
|
||||
}
|
||||
if (vulnRes.ok) {
|
||||
const d = await vulnRes.json();
|
||||
const items = d.Vulnerabilities || d.vulnerabilities || d.items || [];
|
||||
vc = items.length;
|
||||
}
|
||||
updateProjectStats(fc, vc);
|
||||
const res = await apiFetch(`/api/projects/${currentProjectId}/stats`);
|
||||
if (!res.ok) return;
|
||||
const stats = await res.json();
|
||||
updateProjectStats(stats);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectConversations() {
|
||||
const tbody = document.getElementById('project-conversations-tbody');
|
||||
if (!tbody || !currentProjectId) return;
|
||||
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="3">加载中…</td></tr>';
|
||||
const res = await apiFetch(`/api/projects/${currentProjectId}/conversations?limit=100`);
|
||||
if (!res.ok) {
|
||||
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="3">加载失败</td></tr>';
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
const items = data.conversations || [];
|
||||
if (!items.length) {
|
||||
tbody.innerHTML =
|
||||
'<tr class="is-empty-row"><td colspan="3">暂无绑定对话;在对话页选择本项目即可关联</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items
|
||||
.map((conv) => {
|
||||
const id = conv.id;
|
||||
const idEsc = escapeHtml(id);
|
||||
const title = escapeHtml(conv.title || '未命名对话');
|
||||
const updated = formatProjectTime(conv.updatedAt || conv.updated_at, conv.createdAt || conv.created_at);
|
||||
return `<tr>
|
||||
<td class="cell-summary" title="${title}">${title}</td>
|
||||
<td>${escapeHtml(updated)}</td>
|
||||
<td class="col-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)">打开</button>
|
||||
<button type="button" class="projects-action-btn projects-action-btn--mute" data-conv-id="${idEsc}" onclick="unbindConversationFromProject(this.dataset.convId)" title="解除项目绑定">解绑</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function openProjectConversation(conversationId) {
|
||||
if (!conversationId) return;
|
||||
if (typeof switchPage === 'function') {
|
||||
switchPage('chat');
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (typeof loadConversation === 'function') {
|
||||
loadConversation(conversationId);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function unbindConversationFromProject(conversationId) {
|
||||
if (!conversationId || !confirm('解除该对话与当前项目的绑定?')) return;
|
||||
const res = await apiFetch(`/api/conversations/${encodeURIComponent(conversationId)}/project`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectId: '' }),
|
||||
});
|
||||
if (!res.ok) return alert('解绑失败');
|
||||
loadProjectConversations();
|
||||
refreshProjectHeaderStats();
|
||||
}
|
||||
|
||||
let _factDetailKey = null;
|
||||
let _factDetailFact = null;
|
||||
let _projectFactsFilterDebounce = null;
|
||||
|
||||
async function viewProjectFactBody(factKey) {
|
||||
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`);
|
||||
if (!res.ok) return alert('加载失败');
|
||||
const f = await res.json();
|
||||
_factDetailKey = f.fact_key;
|
||||
_factDetailFact = f;
|
||||
document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`;
|
||||
const metaParts = [
|
||||
`分类: ${f.category}`,
|
||||
@@ -483,6 +615,7 @@ async function viewProjectFactBody(factKey) {
|
||||
];
|
||||
if (f.related_vulnerability_id) metaParts.push(`关联漏洞: ${f.related_vulnerability_id}`);
|
||||
if (f.source_conversation_id) metaParts.push(`来源对话: ${f.source_conversation_id}`);
|
||||
if (f.supersedes_fact_id) metaParts.push('含上一版本');
|
||||
document.getElementById('fact-detail-meta').textContent = metaParts.join(' · ');
|
||||
document.getElementById('fact-detail-body').textContent = f.body || '(无 body)';
|
||||
const warnEl = document.getElementById('fact-detail-sparse-warn');
|
||||
@@ -496,6 +629,30 @@ async function viewProjectFactBody(factKey) {
|
||||
warnEl.textContent = '';
|
||||
}
|
||||
}
|
||||
const prevWrap = document.getElementById('fact-detail-prev-wrap');
|
||||
if (prevWrap) {
|
||||
prevWrap.hidden = true;
|
||||
if (f.id && f.supersedes_fact_id) {
|
||||
try {
|
||||
const prevRes = await apiFetch(
|
||||
`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}/previous-version`,
|
||||
);
|
||||
if (prevRes.ok) {
|
||||
const prev = await prevRes.json();
|
||||
prevWrap.hidden = false;
|
||||
document.getElementById('fact-detail-prev-meta').textContent =
|
||||
`归档于 ${formatProjectTime(prev.archived_at)} · 摘要: ${prev.summary || '—'} · 置信度: ${prev.confidence || '—'}`;
|
||||
document.getElementById('fact-detail-prev-body').textContent = prev.body || '(无 body)';
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
const linkBtn = document.getElementById('fact-detail-link-vuln-btn');
|
||||
const createBtn = document.getElementById('fact-detail-create-vuln-btn');
|
||||
if (linkBtn) linkBtn.hidden = false;
|
||||
if (createBtn) createBtn.hidden = false;
|
||||
openProjectsOverlay('fact-detail-modal');
|
||||
}
|
||||
|
||||
@@ -508,6 +665,99 @@ function editFactFromDetail() {
|
||||
function closeFactDetailModal() {
|
||||
closeProjectsOverlay('fact-detail-modal');
|
||||
_factDetailKey = null;
|
||||
_factDetailFact = null;
|
||||
}
|
||||
|
||||
async function linkFactToExistingVulnerability() {
|
||||
const f = _factDetailFact;
|
||||
if (!f || !currentProjectId) return;
|
||||
const res = await apiFetch(`/api/vulnerabilities?project_id=${encodeURIComponent(currentProjectId)}&limit=50`);
|
||||
if (!res.ok) return alert('加载漏洞列表失败');
|
||||
const data = await res.json();
|
||||
const items = data.Vulnerabilities || data.vulnerabilities || data.items || [];
|
||||
if (!items.length) return alert('本项目暂无漏洞,请先创建或让 Agent 记录漏洞');
|
||||
const lines = items.map((v, i) => `${i + 1}. [${v.severity}] ${v.title} (${v.id})`);
|
||||
const pick = prompt(`输入序号以关联事实「${f.fact_key}」:\n\n${lines.join('\n')}`);
|
||||
if (pick == null || pick === '') return;
|
||||
const idx = parseInt(pick, 10) - 1;
|
||||
if (Number.isNaN(idx) || idx < 0 || idx >= items.length) return alert('序号无效');
|
||||
const vulnId = items[idx].id;
|
||||
const upd = await apiFetch(`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fact_key: f.fact_key,
|
||||
category: f.category,
|
||||
summary: f.summary,
|
||||
body: f.body || '',
|
||||
confidence: f.confidence,
|
||||
related_vulnerability_id: vulnId,
|
||||
}),
|
||||
});
|
||||
if (!upd.ok) return alert('关联失败');
|
||||
alert('已关联漏洞');
|
||||
closeFactDetailModal();
|
||||
loadProjectFacts();
|
||||
}
|
||||
|
||||
async function createVulnerabilityFromCurrentFact() {
|
||||
const f = _factDetailFact;
|
||||
if (!f || !currentProjectId) return;
|
||||
let convId =
|
||||
(f.source_conversation_id || '').trim() ||
|
||||
(typeof window.currentConversationId === 'string' ? window.currentConversationId.trim() : '');
|
||||
if (!convId) {
|
||||
convId = prompt('创建漏洞需要对话 ID(可与来源会话一致):', '')?.trim() || '';
|
||||
}
|
||||
if (!convId) return alert('已取消:未提供 conversation_id');
|
||||
const severity = inferSeverityFromFact(f);
|
||||
const body = {
|
||||
conversation_id: convId,
|
||||
project_id: currentProjectId,
|
||||
title: (f.summary || f.fact_key).slice(0, 200),
|
||||
description: `由项目事实 ${f.fact_key} 生成`,
|
||||
severity,
|
||||
status: 'open',
|
||||
type: f.category || 'finding',
|
||||
target: '',
|
||||
proof: f.body || '',
|
||||
impact: '',
|
||||
recommendation: '',
|
||||
};
|
||||
const res = await apiFetch('/api/vulnerabilities', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return alert(err.error || '创建漏洞失败');
|
||||
}
|
||||
const vuln = await res.json();
|
||||
await apiFetch(`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fact_key: f.fact_key,
|
||||
category: f.category,
|
||||
summary: f.summary,
|
||||
body: f.body || '',
|
||||
confidence: f.confidence,
|
||||
related_vulnerability_id: vuln.id,
|
||||
}),
|
||||
});
|
||||
alert(`已创建漏洞并关联:${vuln.title || vuln.id}`);
|
||||
closeFactDetailModal();
|
||||
loadProjectFacts();
|
||||
if (currentProjectTab === 'vulns') loadProjectVulnerabilities();
|
||||
}
|
||||
|
||||
function inferSeverityFromFact(f) {
|
||||
const c = (f.category || '').toLowerCase();
|
||||
const key = (f.fact_key || '').toLowerCase();
|
||||
if (c === 'exploit' || c === 'poc' || key.includes('rce') || key.includes('sqli')) return 'high';
|
||||
if (c === 'finding' || c === 'chain') return 'medium';
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
async function deprecateProjectFactByKey(factKey) {
|
||||
@@ -573,6 +823,7 @@ async function loadProjectVulnerabilities() {
|
||||
<td class="col-actions">
|
||||
<div class="projects-table-actions">
|
||||
<button type="button" class="projects-action-btn projects-action-btn--view" data-vuln-id="${idEsc}" onclick="openVulnerabilityDetail(this.dataset.vulnId)">查看</button>
|
||||
<button type="button" class="projects-action-btn projects-action-btn--view" data-vuln-id="${idEsc}" onclick="viewFactsForVulnerability(this.dataset.vulnId)" title="查看关联事实">事实</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
@@ -587,6 +838,44 @@ function openVulnerabilityDetail(vulnId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function viewFactsForVulnerability(vulnId) {
|
||||
if (!currentProjectId) return;
|
||||
switchProjectTab('facts');
|
||||
const searchEl = document.getElementById('project-facts-search');
|
||||
const catEl = document.getElementById('project-facts-filter-category');
|
||||
const confEl = document.getElementById('project-facts-filter-confidence');
|
||||
const sparseEl = document.getElementById('project-facts-filter-sparse');
|
||||
const hideDepEl = document.getElementById('project-facts-filter-hide-deprecated');
|
||||
if (searchEl) searchEl.value = '';
|
||||
if (catEl) catEl.value = '';
|
||||
if (confEl) confEl.value = '';
|
||||
if (sparseEl) sparseEl.checked = false;
|
||||
if (hideDepEl) hideDepEl.checked = true;
|
||||
const params = new URLSearchParams({ limit: '50', related_vulnerability_id: vulnId });
|
||||
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${params}`);
|
||||
if (!res.ok) return alert('加载关联事实失败');
|
||||
const facts = await res.json();
|
||||
if (!facts.length) {
|
||||
alert('该漏洞暂无关联事实,可在事实详情中「关联漏洞」或「生成漏洞草稿」建立链接');
|
||||
loadProjectFacts();
|
||||
return;
|
||||
}
|
||||
if (facts.length === 1) {
|
||||
viewProjectFactBody(facts[0].fact_key);
|
||||
return;
|
||||
}
|
||||
const pick = prompt(
|
||||
`该漏洞关联 ${facts.length} 条事实,输入序号查看:\n${facts.map((f, i) => `${i + 1}. ${f.fact_key}`).join('\n')}`,
|
||||
);
|
||||
if (pick == null || pick === '') {
|
||||
loadProjectFacts();
|
||||
return;
|
||||
}
|
||||
const idx = parseInt(pick, 10) - 1;
|
||||
if (facts[idx]) viewProjectFactBody(facts[idx].fact_key);
|
||||
else loadProjectFacts();
|
||||
}
|
||||
|
||||
function openProjectsOverlay(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
@@ -683,6 +972,7 @@ async function saveProjectSettings() {
|
||||
description: document.getElementById('project-edit-description').value.trim(),
|
||||
scope_json: scopeRaw,
|
||||
status: document.getElementById('project-edit-status')?.value || 'active',
|
||||
pinned: !!document.getElementById('project-edit-pinned')?.checked,
|
||||
};
|
||||
const res = await apiFetch(`/api/projects/${currentProjectId}`, {
|
||||
method: 'PUT',
|
||||
@@ -1143,6 +1433,7 @@ window.toggleChatProjectPanel = toggleChatProjectPanel;
|
||||
window.closeChatProjectPanel = closeChatProjectPanel;
|
||||
window.selectChatProject = selectChatProject;
|
||||
window.prefetchProjectsForChat = prefetchProjectsForChat;
|
||||
window.ensureDefaultActiveProjectForNewChat = ensureDefaultActiveProjectForNewChat;
|
||||
window.getActiveProjectId = getActiveProjectId;
|
||||
window.getProjectName = getProjectName;
|
||||
window.viewProjectFactBody = viewProjectFactBody;
|
||||
@@ -1153,5 +1444,12 @@ window.restoreProjectFactByKey = restoreProjectFactByKey;
|
||||
window.openVulnerabilitiesForProject = openVulnerabilitiesForProject;
|
||||
window.openVulnerabilityDetail = openVulnerabilityDetail;
|
||||
window.filterProjectsList = filterProjectsList;
|
||||
window.debouncedLoadProjectFacts = debouncedLoadProjectFacts;
|
||||
window.linkFactToExistingVulnerability = linkFactToExistingVulnerability;
|
||||
window.createVulnerabilityFromCurrentFact = createVulnerabilityFromCurrentFact;
|
||||
window.viewFactsForVulnerability = viewFactsForVulnerability;
|
||||
window.openProjectConversation = openProjectConversation;
|
||||
window.unbindConversationFromProject = unbindConversationFromProject;
|
||||
window.loadProjectConversations = loadProjectConversations;
|
||||
window.rebuildProjectNameMap = rebuildProjectNameMap;
|
||||
window.projectNameById = projectNameById;
|
||||
|
||||
@@ -932,6 +932,7 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
${vuln.proof ? `<div class="vulnerability-proof"><strong>${escapeHtml(vulnT('vulnerabilityPage.detailProof'))}:</strong><pre>${escapeHtml(vuln.proof)}</pre></div>` : ''}
|
||||
${vuln.impact ? `<div class="vulnerability-impact"><strong>${escapeHtml(vulnT('vulnerabilityPage.detailImpact'))}:</strong> ${escapeHtml(vuln.impact)}</div>` : ''}
|
||||
${vuln.recommendation ? `<div class="vulnerability-recommendation"><strong>${escapeHtml(vulnT('vulnerabilityPage.detailRecommendation'))}:</strong> ${escapeHtml(vuln.recommendation)}</div>` : ''}
|
||||
<div class="vulnerability-related-facts" id="vuln-related-facts-${vuln.id}" data-project-id="${escapeHtml(vuln.project_id || '')}" data-vuln-id="${escapeHtml(vuln.id)}" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1295,12 +1296,76 @@ function toggleVulnerabilityDetails(id) {
|
||||
if (content.style.display === 'none') {
|
||||
content.style.display = 'block';
|
||||
icon.style.transform = 'rotate(90deg)';
|
||||
loadVulnerabilityRelatedFacts(id).catch((e) => console.warn(e));
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开漏洞详情时加载关联项目事实 */
|
||||
async function loadVulnerabilityRelatedFacts(vulnId) {
|
||||
const el = document.getElementById(`vuln-related-facts-${vulnId}`);
|
||||
if (!el || el.dataset.loaded === '1') return;
|
||||
const projectId = (el.dataset.projectId || '').trim();
|
||||
if (!projectId) {
|
||||
el.hidden = true;
|
||||
el.dataset.loaded = '1';
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
el.innerHTML = '<span>加载关联事实…</span>';
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/facts?related_vulnerability_id=${encodeURIComponent(vulnId)}&limit=20&exclude_deprecated=true`,
|
||||
);
|
||||
if (!res.ok) throw new Error('fetch failed');
|
||||
const facts = await res.json();
|
||||
if (!Array.isArray(facts) || !facts.length) {
|
||||
el.innerHTML =
|
||||
'<strong>关联事实</strong><p style="margin:6px 0 0;color:#64748b">暂无;可在「项目管理」事实详情中关联或生成漏洞草稿。</p>';
|
||||
el.dataset.loaded = '1';
|
||||
return;
|
||||
}
|
||||
const items = facts
|
||||
.map((f) => {
|
||||
const key = escapeHtml(f.fact_key);
|
||||
const sum = escapeHtml((f.summary || '').slice(0, 120));
|
||||
const pid = escapeHtml(projectId);
|
||||
const rawKey = escapeHtml(f.fact_key);
|
||||
return `<li><a role="button" href="#" data-project-id="${pid}" data-fact-key="${rawKey}" onclick="event.preventDefault();openProjectFactFromVulnerability(this.dataset.projectId,this.dataset.factKey)"><code>${key}</code></a> — ${sum}</li>`;
|
||||
})
|
||||
.join('');
|
||||
el.innerHTML = `<strong>关联事实(${facts.length})</strong><ul>${items}</ul>`;
|
||||
el.dataset.loaded = '1';
|
||||
} catch (e) {
|
||||
el.innerHTML = '<strong>关联事实</strong><p style="margin:6px 0 0;color:#b91c1c">加载失败</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function openProjectFactFromVulnerability(projectId, factKey) {
|
||||
if (!projectId || !factKey) return;
|
||||
if (typeof switchPage === 'function') {
|
||||
switchPage('projects');
|
||||
}
|
||||
setTimeout(async () => {
|
||||
if (typeof window.initProjectsPage === 'function') {
|
||||
await window.initProjectsPage();
|
||||
}
|
||||
if (typeof window.selectProject === 'function') {
|
||||
await window.selectProject(projectId);
|
||||
}
|
||||
if (typeof window.switchProjectTab === 'function') {
|
||||
window.switchProjectTab('facts');
|
||||
}
|
||||
if (typeof window.viewProjectFactBody === 'function') {
|
||||
window.viewProjectFactBody(factKey);
|
||||
}
|
||||
}, 350);
|
||||
}
|
||||
|
||||
window.openProjectFactFromVulnerability = openProjectFactFromVulnerability;
|
||||
|
||||
// HTML转义
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
|
||||
+94
-14
@@ -1453,6 +1453,8 @@
|
||||
<div class="projects-detail-stats" id="projects-detail-stats">
|
||||
<span class="projects-stat-chip" id="project-stat-facts">0 条事实</span>
|
||||
<span class="projects-stat-chip" id="project-stat-vulns">0 个漏洞</span>
|
||||
<span class="projects-stat-chip" id="project-stat-conversations">0 个对话</span>
|
||||
<span class="projects-stat-chip projects-stat-chip--warn" id="project-stat-sparse" hidden>0 待补全</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-detail-header-actions">
|
||||
@@ -1462,13 +1464,63 @@
|
||||
</header>
|
||||
<nav class="projects-tabs" role="tablist">
|
||||
<button type="button" id="project-tab-facts" class="projects-tab is-active" role="tab" onclick="switchProjectTab('facts')">事实黑板</button>
|
||||
<button type="button" id="project-tab-conversations" class="projects-tab" role="tab" onclick="switchProjectTab('conversations')">关联对话</button>
|
||||
<button type="button" id="project-tab-vulns" class="projects-tab" role="tab" onclick="switchProjectTab('vulns')">关联漏洞</button>
|
||||
<button type="button" id="project-tab-settings" class="projects-tab" role="tab" onclick="switchProjectTab('settings')">设置</button>
|
||||
</nav>
|
||||
<div id="project-panel-facts" class="projects-panel" role="tabpanel">
|
||||
<div class="projects-panel-toolbar">
|
||||
<span class="projects-panel-hint">索引仅含 key + 摘要(须含「什么+在哪+如何验证」);攻击链/POC 写在 body,Agent 通过 get_project_fact 复现</span>
|
||||
<button class="btn-primary btn-small" type="button" onclick="showAddFactModal()">+ 添加事实</button>
|
||||
<div class="projects-fact-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>索引仅含 <strong>key</strong> 与 <strong>摘要</strong>(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 <strong>body</strong>,Agent 通过 <code>get_project_fact</code> 复现</span>
|
||||
</p>
|
||||
<div class="projects-fact-toolbar-filters" role="search">
|
||||
<label class="projects-fact-filter-field projects-fact-filter-field--search">
|
||||
<span class="sr-only">搜索事实</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-facts-search" placeholder="搜索 key、摘要、body…" oninput="debouncedLoadProjectFacts()" autocomplete="off">
|
||||
</label>
|
||||
<label class="projects-fact-filter-field">
|
||||
<span class="projects-fact-filter-label">分类</span>
|
||||
<select id="project-facts-filter-category" onchange="loadProjectFacts()">
|
||||
<option value="">全部</option>
|
||||
<option value="target">target</option>
|
||||
<option value="auth">auth</option>
|
||||
<option value="infra">infra</option>
|
||||
<option value="business">business</option>
|
||||
<option value="finding">finding</option>
|
||||
<option value="chain">chain</option>
|
||||
<option value="exploit">exploit</option>
|
||||
<option value="poc">poc</option>
|
||||
<option value="note">note</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="projects-fact-filter-field">
|
||||
<span class="projects-fact-filter-label">置信度</span>
|
||||
<select id="project-facts-filter-confidence" onchange="loadProjectFacts()">
|
||||
<option value="">全部</option>
|
||||
<option value="confirmed">已确认</option>
|
||||
<option value="tentative">待确认</option>
|
||||
<option value="deprecated">已废弃</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="projects-fact-filter-toggles" role="group" aria-label="显示选项">
|
||||
<label class="projects-fact-toggle">
|
||||
<input type="checkbox" id="project-facts-filter-sparse" onchange="loadProjectFacts()">
|
||||
<span>仅待补全</span>
|
||||
</label>
|
||||
<label class="projects-fact-toggle">
|
||||
<input type="checkbox" id="project-facts-filter-hide-deprecated" checked onchange="loadProjectFacts()">
|
||||
<span>隐藏废弃</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-table-wrap">
|
||||
<table class="data-table data-table--projects">
|
||||
@@ -1477,6 +1529,17 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div id="project-panel-conversations" class="projects-panel" role="tabpanel" hidden>
|
||||
<div class="projects-panel-toolbar">
|
||||
<span class="projects-panel-hint">绑定到本项目的对话;点击可打开会话</span>
|
||||
</div>
|
||||
<div class="projects-table-wrap">
|
||||
<table class="data-table data-table--projects">
|
||||
<thead><tr><th>标题</th><th>更新</th><th class="col-actions">操作</th></tr></thead>
|
||||
<tbody id="project-conversations-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div id="project-panel-vulns" class="projects-panel" role="tabpanel" hidden>
|
||||
<div class="projects-panel-toolbar">
|
||||
<span class="projects-panel-hint">本项目下记录的漏洞汇总</span>
|
||||
@@ -1526,9 +1589,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="projects-form-field">
|
||||
<label class="projects-filter-check projects-pin-toggle">
|
||||
<input type="checkbox" id="project-edit-pinned"> 置顶项目(列表优先显示)
|
||||
</label>
|
||||
</div>
|
||||
<div class="projects-form-field">
|
||||
<label for="project-edit-description">描述</label>
|
||||
<textarea id="project-edit-description" class="form-input" rows="4" placeholder="测试目标、授权范围、联系人、注意事项…"></textarea>
|
||||
<textarea id="project-edit-description" class="form-input" rows="3" placeholder="测试目标、授权范围、联系人、注意事项…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1572,14 +1640,14 @@
|
||||
<button class="btn-secondary btn-small btn-danger-outline" type="button" onclick="deleteCurrentProject()">删除项目</button>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="projects-settings-footer">
|
||||
<span class="projects-settings-footer-hint">修改后请点击保存以同步到服务器</span>
|
||||
<button class="btn-primary" type="button" onclick="saveProjectSettings()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
保存更改
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
<footer class="projects-settings-footer">
|
||||
<span class="projects-settings-footer-hint">修改后请点击保存以同步到服务器</span>
|
||||
<button class="btn-primary" type="button" onclick="saveProjectSettings()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
|
||||
保存更改
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -4046,11 +4114,23 @@
|
||||
</div>
|
||||
<div class="projects-modal-body">
|
||||
<p id="fact-detail-sparse-warn" class="projects-fact-sparse-warn" hidden></p>
|
||||
<div id="fact-detail-prev-wrap" class="fact-detail-prev-wrap" hidden>
|
||||
<h4 class="fact-detail-prev-title">上一版本</h4>
|
||||
<p id="fact-detail-prev-meta" class="projects-modal-subtitle"></p>
|
||||
<pre id="fact-detail-prev-body" class="fact-detail-body fact-detail-body--muted"></pre>
|
||||
</div>
|
||||
<h4 class="fact-detail-current-title">当前版本</h4>
|
||||
<pre id="fact-detail-body" class="fact-detail-body"></pre>
|
||||
</div>
|
||||
<div class="projects-modal-footer">
|
||||
<button class="btn-secondary" type="button" onclick="closeFactDetailModal()">关闭</button>
|
||||
<button class="btn-primary" type="button" id="fact-detail-edit-btn" onclick="editFactFromDetail()">编辑</button>
|
||||
<div class="projects-modal-footer projects-modal-footer--split">
|
||||
<div class="projects-modal-footer-left">
|
||||
<button class="btn-secondary btn-small" type="button" id="fact-detail-link-vuln-btn" onclick="linkFactToExistingVulnerability()" hidden>关联漏洞</button>
|
||||
<button class="btn-secondary btn-small" type="button" id="fact-detail-create-vuln-btn" onclick="createVulnerabilityFromCurrentFact()" hidden>生成漏洞草稿</button>
|
||||
</div>
|
||||
<div class="projects-modal-footer-right">
|
||||
<button class="btn-secondary" type="button" onclick="closeFactDetailModal()">关闭</button>
|
||||
<button class="btn-primary" type="button" id="fact-detail-edit-btn" onclick="editFactFromDetail()">编辑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user