Add files via upload

This commit is contained in:
公明
2026-05-27 11:40:10 +08:00
committed by GitHub
parent c0f0861b31
commit 3f9dbb4214
22 changed files with 988 additions and 153 deletions
+328 -61
View File
@@ -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;
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+65
View File
@@ -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');