From 44ced9886340ceaa51d6566666b83cdd5a01c6aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Tue, 26 May 2026 14:24:32 +0800 Subject: [PATCH] Add files via upload --- README.md | 1 + README_CN.md | 1 + web/static/css/style.css | 1085 ++++++++++++++++++++++++++++++++ web/static/i18n/en-US.json | 8 + web/static/i18n/zh-CN.json | 8 + web/static/js/chat.js | 15 + web/static/js/info-collect.js | 7 +- web/static/js/projects.js | 907 ++++++++++++++++++++++++++ web/static/js/roles.js | 41 +- web/static/js/router.js | 77 ++- web/static/js/tasks.js | 11 +- web/static/js/vulnerability.js | 196 +++++- web/templates/index.html | 317 +++++++++- 13 files changed, 2641 insertions(+), 33 deletions(-) create mode 100644 web/static/js/projects.js diff --git a/README.md b/README.md index 7e852cba..bc3d70c0 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte - 🔒 Password-protected web UI, audit logs, and SQLite persistence - 📚 Knowledge base (RAG) with embedding-based vector retrieval (cosine similarity), optional **Eino Compose** indexing pipeline, and configurable post-retrieval budgets / reranking hooks - 📁 Conversation grouping with pinning, rename, and batch management +- 📂 **Project management**: group conversations and vulnerabilities by project; **shared facts** (project blackboard) persist cross-session context (targets, env, auth notes) with auto-injection for agents and MCP tools (`upsert_project_fact`, `get_project_fact`, …) - 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics - 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially - 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions diff --git a/README_CN.md b/README_CN.md index 6ba2a1d4..2ebbc51b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -112,6 +112,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集 - 🔒 Web 登录保护、审计日志、SQLite 持久化 - 📚 知识库(RAG):向量嵌入与余弦相似度检索(与 Eino `retriever.Retriever` 语义一致),可选 **Eino Compose** 索引流水线及检索后处理(预算、重排等配置项) - 📁 对话分组管理:支持分组创建、置顶、重命名、删除等操作 +- 📂 **项目管理**:按项目归类对话与漏洞;**共享事实**(项目黑板)在多会话间沉淀目标/环境/认证等认知,自动注入 Agent 上下文,支持 MCP 工具读写(`upsert_project_fact`、`get_project_fact` 等) - 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板 - 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪 - 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制 diff --git a/web/static/css/style.css b/web/static/css/style.css index 4593ecf3..07941ed1 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -17360,6 +17360,49 @@ header { flex-wrap: wrap; } +.vulnerability-project-badge { + display: inline-block; + font-size: 0.75rem; + padding: 2px 8px; + border-radius: 999px; + background: var(--bg-tertiary, rgba(99, 102, 241, 0.12)); + color: var(--text-secondary); + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vulnerability-project-badge--unbound { + opacity: 0.75; + font-style: italic; +} + +.vuln-detail-field-select, +.vulnerability-project-bind-select { + flex: 1; + min-width: 0; + width: 100%; + margin: 0; + padding: 8px 10px; + padding-right: 28px; + border-radius: 6px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + font-size: 0.8125rem; + line-height: 1.45; + color: var(--text-primary); + font-family: inherit; + cursor: pointer; + appearance: auto; +} + +.vuln-detail-field-select:focus, +.vulnerability-project-bind-select:focus { + outline: none; + border-color: var(--accent-color); +} + .severity-badge { display: inline-block; padding: 4px 12px; @@ -17542,6 +17585,10 @@ header { border-color: var(--border-color); } +.vuln-detail-field__row .vuln-detail-field-select { + flex: 1; +} + .vulnerability-proof, .vulnerability-impact, .vulnerability-recommendation { @@ -20873,3 +20920,1041 @@ button.chat-files-dropdown-item:hover:not(:disabled) { margin-bottom: 12px; } +/* 通用数据表格(项目管理等) */ +.data-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 0.875rem; +} +.data-table thead th { + text-align: left; + font-weight: 600; + font-size: 0.75rem; + text-transform: none; + letter-spacing: 0.02em; + color: var(--text-secondary, #64748b); + padding: 10px 14px; + background: #f8fafc; + border-bottom: 1px solid var(--border-color, #e2e8f0); + white-space: nowrap; +} +.data-table thead th:first-child { + border-top-left-radius: 10px; +} +.data-table thead th:last-child { + border-top-right-radius: 10px; +} +.data-table tbody td { + padding: 12px 14px; + border-bottom: 1px solid var(--border-color, #eef2f7); + color: var(--text-primary, #1e293b); + vertical-align: middle; +} +.data-table tbody tr:last-child td { + border-bottom: none; +} +.data-table tbody tr:hover td { + background: #f8fafc; +} +.data-table tbody tr.is-empty-row td { + text-align: center; + color: var(--text-muted, #94a3b8); + padding: 32px 14px; + font-size: 0.875rem; +} +.data-table tbody tr.is-empty-row:hover td { + background: transparent; +} +.data-table .col-actions { + width: 1%; + white-space: nowrap; + text-align: right; +} +.data-table code { + font-size: 0.8125rem; + padding: 2px 8px; + border-radius: 6px; + background: #f1f5f9; + color: #0f172a; + border: 1px solid #e2e8f0; +} +.data-table .cell-summary { + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 项目管理 */ +#page-projects.page { + background: #f8fafc; +} +#page-projects .page-header { + background: #fff; + border-bottom: 1px solid #e2e8f0; +} +#page-projects .page-content.projects-page-layout { + display: flex; + gap: 16px; + min-height: calc(100vh - 128px); + padding: 16px 20px 24px; + background: transparent; +} +.projects-sidebar-card { + width: 260px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: #ffffff; + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 14px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); + overflow: hidden; + max-height: calc(100vh - 160px); +} +.projects-sidebar-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid #eef2f7; + background: linear-gradient(180deg, #fafbfc 0%, #ffffff 100%); +} +.projects-sidebar-title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-secondary, #64748b); +} +.projects-sidebar-count { + font-size: 0.75rem; + font-weight: 600; + color: #0066ff; + background: rgba(0, 102, 255, 0.1); + padding: 2px 8px; + border-radius: 999px; +} +.projects-sidebar-search { + padding: 8px 10px 4px; + border-bottom: 1px solid #f1f5f9; +} +.projects-sidebar-search .form-input { + width: 100%; + box-sizing: border-box; + font-size: 0.8125rem; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid #e2e8f0; + background: #f8fafc; +} +.projects-sidebar-search .form-input:focus { + background: #fff; + border-color: #0066ff; + outline: none; + box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1); +} +.projects-list { + flex: 1; + overflow-y: auto; + padding: 6px 8px 10px; +} +.projects-list-item { + position: relative; + padding: 10px 12px 10px 14px; + border-radius: 8px; + cursor: pointer; + font-size: 0.875rem; + margin-bottom: 2px; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s; +} +.projects-list-item::before { + content: ''; + position: absolute; + left: 4px; + top: 10px; + bottom: 10px; + width: 3px; + border-radius: 3px; + background: transparent; + transition: background 0.15s; +} +.projects-list-item:hover { + background: #f8fafc; +} +.projects-list-item.is-active { + background: #eff6ff; + border-color: #bfdbfe; +} +.projects-list-item.is-active::before { + background: #0066ff; +} +.projects-list-item.is-archived .projects-list-item-name { + color: #94a3b8; +} +.projects-list-item-body { + min-width: 0; +} +.projects-list-item-name { + font-weight: 600; + color: var(--text-primary, #0f172a); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.projects-list-item-meta { + font-size: 0.75rem; + color: var(--text-muted, #94a3b8); + margin-top: 2px; +} +.projects-list-item-badge { + font-size: 0.6875rem; + padding: 1px 6px; + border-radius: 4px; + background: #f1f5f9; + color: #64748b; + margin-left: 4px; +} +.projects-detail { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + min-height: 420px; +} +/* display:flex 会覆盖 [hidden],须显式隐藏 */ +.projects-detail-placeholder[hidden], +.projects-detail-inner[hidden] { + display: none !important; +} +.projects-detail.has-project .projects-detail-placeholder { + display: none !important; +} +.projects-detail:not(.has-project) .projects-detail-inner { + display: none !important; +} +.projects-detail-placeholder { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 48px 32px; + background: #ffffff; + border: 1px dashed #cbd5e1; + border-radius: 14px; + min-height: 420px; +} +.projects-placeholder-icon { + font-size: 3rem; + margin-bottom: 16px; + opacity: 0.85; +} +.projects-detail-placeholder h3 { + margin: 0 0 8px; + font-size: 1.125rem; + color: var(--text-primary, #0f172a); +} +.projects-detail-placeholder p { + margin: 0 0 24px; + max-width: 400px; + font-size: 0.875rem; + line-height: 1.6; + color: var(--text-secondary, #64748b); +} +.projects-detail-inner { + flex: 1; + display: flex; + flex-direction: column; + background: #ffffff; + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 14px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); + overflow: hidden; + min-height: 420px; +} +.projects-detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 20px 24px 18px; + border-bottom: 1px solid #eef2f7; + background: #fff; +} +.projects-detail-header-main { + min-width: 0; + flex: 1; +} +.projects-detail-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} +.projects-detail-title { + margin: 0; + font-size: 1.375rem; + font-weight: 600; + color: #0f172a; + letter-spacing: -0.02em; +} +.projects-status-pill { + display: inline-flex; + align-items: center; + font-size: 0.6875rem; + font-weight: 600; + padding: 3px 10px; + border-radius: 999px; + line-height: 1.2; +} +.projects-status-pill--active { + background: #dcfce7; + color: #166534; +} +.projects-status-pill--archived { + background: #f1f5f9; + color: #64748b; +} +.projects-detail-meta { + margin: 6px 0 0; + font-size: 0.8125rem; + color: #94a3b8; +} +.projects-detail-desc { + margin: 10px 0 0; + font-size: 0.875rem; + color: #475569; + line-height: 1.55; + max-width: 640px; +} +.projects-detail-stats { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} +.projects-stat-chip { + font-size: 0.75rem; + font-weight: 500; + color: #475569; + background: #f1f5f9; + border: 1px solid #e2e8f0; + padding: 4px 10px; + border-radius: 999px; +} +.projects-detail-header-actions { + flex-shrink: 0; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-start; +} +.projects-tabs { + display: flex; + gap: 6px; + padding: 12px 24px; + background: #f8fafc; + border-bottom: 1px solid #eef2f7; +} +.projects-tab { + padding: 8px 16px; + border: none; + background: transparent; + color: #64748b; + border-radius: 8px; + cursor: pointer; + font-size: 0.8125rem; + font-weight: 500; + transition: background 0.15s, color 0.15s, box-shadow 0.15s; +} +.projects-tab:hover { + color: #0f172a; + background: rgba(255, 255, 255, 0.7); +} +.projects-tab.is-active { + color: #0066ff; + background: #fff; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); +} +.projects-panel { + flex: 1; + padding: 16px 24px 24px; + overflow: auto; + min-height: 0; +} +/* display:flex 会覆盖 [hidden] 默认 display:none,非激活 Tab 会叠在事实黑板下方 */ +.projects-panel[hidden] { + display: none !important; +} +.projects-panel-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; + flex-wrap: wrap; + padding: 10px 14px; + background: #f8fafc; + border: 1px solid #eef2f7; + border-radius: 10px; +} +.projects-panel-hint { + font-size: 0.8125rem; + color: #64748b; + line-height: 1.45; +} +.projects-table-wrap { + border: 1px solid #e2e8f0; + border-radius: 12px; + overflow: hidden; + background: #fff; +} +.data-table--projects .col-actions { + width: auto; + min-width: 240px; + text-align: left; +} +.data-table--projects thead th.col-actions { + text-align: left; +} +.projects-table-actions { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} +.projects-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 5px 11px; + font-size: 0.75rem; + font-weight: 500; + line-height: 1.25; + border-radius: 7px; + border: 1px solid #e2e8f0; + background: #fff; + color: #475569; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; +} +.projects-action-btn:hover { + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); +} +.projects-action-btn--edit:hover { + border-color: #86efac; + color: #15803d; + background: #f0fdf4; +} +.projects-action-btn--view:hover { + border-color: #93c5fd; + color: #2563eb; + background: #eff6ff; +} +.projects-action-btn--mute:hover { + border-color: #fcd34d; + color: #b45309; + background: #fffbeb; +} +.projects-action-btn--danger { + color: #dc2626; + border-color: #fecaca; + background: #fff; +} +.projects-action-btn--danger:hover { + border-color: #f87171; + 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; + display: flex; + flex-direction: column; + gap: 0; + flex: 1; + min-height: 0; +} +.projects-settings-intro { + padding: 18px 24px 14px; + 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; + font-size: 0.8125rem; + color: #64748b; + line-height: 1.5; +} +.projects-settings-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); + gap: 18px; + align-items: stretch; + padding: 20px 24px; +} +.projects-settings-card { + display: flex; + flex-direction: column; + 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); + 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--scope { + min-height: 300px; +} +.projects-settings-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 16px 20px; + border-bottom: 1px solid #f1f5f9; + background: #fafbfc; +} +.projects-settings-card-head-left { + display: flex; + align-items: flex-start; + gap: 12px; + min-width: 0; +} +.projects-settings-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 10px; + flex-shrink: 0; +} +.projects-settings-icon--blue { + background: #eff6ff; + color: #2563eb; +} +.projects-settings-icon--violet { + background: #f5f3ff; + color: #7c3aed; +} +.projects-settings-icon--red { + background: #fef2f2; + color: #dc2626; +} +.projects-settings-card-head .projects-settings-card-title { + margin: 0; +} +.projects-settings-card-head .projects-settings-card-hint { + margin: 3px 0 0; +} +.projects-settings-card-body { + padding: 18px 20px 20px; + flex: 1; + min-height: 0; +} +.projects-settings-card-body--fill { + display: flex; + flex-direction: column; + flex: 1; + min-height: 200px; + gap: 10px; +} +.projects-scope-toolbar { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +.projects-scope-toolbar .btn-ghost { + border-radius: 8px; + color: #64748b; + font-size: 0.75rem; + padding: 5px 10px; +} +.projects-scope-toolbar .btn-ghost:hover { + background: #e2e8f0; + border-color: #e2e8f0; + color: #0f172a; +} +.projects-scope-editor { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + 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; + resize: vertical; + border: none !important; + border-radius: 0 !important; + background: #0f172a !important; + color: #e2e8f0 !important; + padding: 14px 16px !important; + font-size: 0.8125rem !important; + line-height: 1.6 !important; + box-shadow: none !important; +} +.projects-scope-textarea:focus { + outline: none; + box-shadow: none !important; +} +.projects-scope-textarea::placeholder { + color: #475569; +} +.projects-scope-footnote { + margin: 0; + font-size: 0.75rem; + color: #94a3b8; + line-height: 1.45; +} +.projects-scope-footnote code { + font-size: 0.6875rem; + padding: 1px 5px; + border-radius: 4px; + background: #f1f5f9; + color: #475569; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} +.projects-status-select-wrap { + position: relative; +} +.projects-status-select { + appearance: none; + padding-right: 32px !important; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + cursor: pointer; +} +.projects-settings-card--danger { + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 20px; + margin: 0 24px 16px; + padding: 16px 20px; + background: linear-gradient(135deg, #fffbfb 0%, #fff 50%); + 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; +} +.projects-settings-card--danger .projects-settings-card-title { + margin: 0 0 4px; +} +.projects-settings-card--danger .projects-settings-card-hint { + margin: 0; + max-width: 560px; +} +.projects-settings-card-title { + font-size: 0.9375rem; + font-weight: 600; + color: #0f172a; + letter-spacing: -0.01em; +} +.projects-settings-card-hint { + font-size: 0.8125rem; + color: #94a3b8; + line-height: 1.45; +} +.projects-settings-danger-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + flex-shrink: 0; +} +.projects-settings-footer { + 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; +} +.projects-settings-footer-hint { + font-size: 0.8125rem; + color: #94a3b8; +} +.projects-settings-footer .btn-primary { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + font-size: 0.875rem; + font-weight: 600; + border-radius: 10px; + box-shadow: 0 1px 2px rgba(0, 102, 255, 0.2), 0 4px 12px rgba(0, 102, 255, 0.15); +} +.projects-settings-footer .btn-primary svg { + flex-shrink: 0; +} +.projects-form-row--2 { + display: grid; + grid-template-columns: 1fr 168px; + gap: 16px; +} +.projects-form-field--fill { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + margin-bottom: 0; +} +@media (max-width: 960px) { + .projects-settings-grid { + grid-template-columns: 1fr; + padding: 16px; + } + .projects-settings-card--scope { + min-height: 0; + } + .projects-form-row--2 { + grid-template-columns: 1fr; + } + .projects-settings-card--danger { + flex-direction: column; + align-items: stretch; + margin: 0 16px 12px; + } + .projects-settings-danger-actions { + justify-content: flex-end; + } + .projects-settings-footer { + flex-direction: column; + align-items: stretch; + padding: 14px 16px; + } + .projects-settings-footer .btn-primary { + justify-content: center; + width: 100%; + } + .projects-settings-intro { + padding: 14px 16px 12px; + } +} +.projects-form-field { + margin-bottom: 14px; +} +.projects-form-field:last-child { + margin-bottom: 0; +} +.projects-form-field label { + display: block; + font-size: 0.8125rem; + font-weight: 600; + color: #475569; + margin-bottom: 6px; +} +.projects-form-field .form-input { + width: 100%; + box-sizing: border-box; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 10px 12px; + font-size: 0.875rem; + transition: border-color 0.15s, box-shadow 0.15s; +} +.projects-form-field .form-input:focus { + outline: none; + border-color: #0066ff; + box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1); +} +.form-input--mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; + line-height: 1.5; +} +.projects-form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +@media (max-width: 640px) { + .projects-form-row { + grid-template-columns: 1fr; + } +} +.btn-danger-outline { + color: #dc2626; + border-color: #fecaca; +} +.btn-danger-outline:hover { + background: #fef2f2; + border-color: #f87171; +} +.projects-empty { + padding: 24px 12px; + text-align: center; + font-size: 0.875rem; + color: var(--text-muted, #94a3b8); + line-height: 1.5; +} +.projects-empty-btn { + margin-top: 12px; +} +.projects-confidence { + display: inline-block; + font-size: 0.6875rem; + font-weight: 600; + padding: 3px 8px; + border-radius: 999px; + text-transform: lowercase; +} +.projects-confidence--confirmed { + background: #dcfce7; + color: #166534; +} +.projects-confidence--tentative { + background: #fef3c7; + color: #92400e; +} +.projects-confidence--deprecated { + background: #f1f5f9; + color: #64748b; +} +.projects-severity { + display: inline-block; + font-size: 0.6875rem; + font-weight: 600; + padding: 3px 8px; + border-radius: 6px; + text-transform: lowercase; +} +.projects-severity--critical { background: #fee2e2; color: #991b1b; } +.projects-severity--high { background: #ffedd5; color: #9a3412; } +.projects-severity--medium { background: #fef9c3; color: #854d0e; } +.projects-severity--low { background: #dbeafe; color: #1e40af; } +.projects-severity--info { background: #f1f5f9; color: #475569; } +.projects-show-archived-label { + font-size: 0.8125rem; + color: var(--text-secondary, #64748b); + margin-right: 8px; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; +} +/* 项目管理弹窗遮罩 */ +.modal-overlay.projects-modal-overlay { + display: none; + position: fixed; + inset: 0; + z-index: 10050; + align-items: center; + justify-content: center; + padding: 24px 16px; + box-sizing: border-box; + background: rgba(15, 23, 42, 0.45); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + animation: projectsOverlayIn 0.2s ease-out; +} +@keyframes projectsOverlayIn { + from { opacity: 0; } + to { opacity: 1; } +} +.projects-modal-dialog { + width: 100%; + max-width: 480px; + max-height: min(90vh, 640px); + display: flex; + flex-direction: column; + background: #ffffff; + border-radius: 16px; + box-shadow: + 0 24px 48px rgba(15, 23, 42, 0.18), + 0 0 0 1px rgba(15, 23, 42, 0.06); + overflow: hidden; + animation: projectsDialogIn 0.25s cubic-bezier(0.22, 1, 0.36, 1); +} +.projects-modal-dialog--wide { + max-width: 640px; +} +@keyframes projectsDialogIn { + from { + opacity: 0; + transform: scale(0.96) translateY(12px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} +.projects-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 20px 22px 16px; + border-bottom: 1px solid #eef2f7; + background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%); + flex-shrink: 0; +} +.projects-modal-header-text { + min-width: 0; + flex: 1; +} +.projects-modal-header h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: #0f172a; + line-height: 1.3; +} +.projects-modal-subtitle { + margin: 4px 0 0; + font-size: 0.8125rem; + color: #64748b; + line-height: 1.45; + font-weight: 400; +} +.projects-modal-close { + flex-shrink: 0; + width: 32px; + height: 32px; + border: none; + border-radius: 8px; + background: transparent; + color: #64748b; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s; +} +.projects-modal-close:hover { + background: #f1f5f9; + color: #0f172a; +} +.projects-modal-body { + padding: 20px 22px; + overflow-y: auto; + flex: 1; + min-height: 0; +} +.projects-modal-body .projects-form-field { + margin-bottom: 18px; +} +.projects-modal-body .projects-form-field:last-child { + margin-bottom: 0; +} +.projects-modal-body .form-input { + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 10px 12px; + font-size: 0.875rem; + transition: border-color 0.15s, box-shadow 0.15s; +} +.projects-modal-body .form-input:focus { + outline: none; + border-color: #0066ff; + box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.12); +} +.projects-modal-body .projects-form-field label .required { + color: #dc2626; + font-weight: 600; +} +.projects-modal-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 14px 22px 18px; + border-top: 1px solid #eef2f7; + background: #fafbfc; + flex-shrink: 0; +} +.projects-modal-footer .btn-primary { + min-width: 100px; +} +body.projects-modal-open { + overflow: hidden; +} +.fact-detail-body { + max-height: 50vh; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 0.8125rem; + line-height: 1.55; + padding: 14px 16px; + background: #f8fafc; + border-radius: 10px; + border: 1px solid #e2e8f0; + color: #334155; +} +.vulnerability-filter-field--project select { + min-width: 120px; + max-width: 160px; +} +/* 对话区项目选择器(与角色/代理模式共用 role-selector-*) */ +.project-selector-wrapper .role-selector-text { + max-width: 108px; + overflow: hidden; + text-overflow: ellipsis; +} +.chat-project-panel { + width: 280px; +} +.chat-project-panel-loading, +.chat-project-panel-empty { + padding: 16px 14px; + font-size: 0.8125rem; + color: #64748b; + text-align: center; +} +@media (max-width: 900px) { + #page-projects .page-content.projects-page-layout { + flex-direction: column; + } + .projects-sidebar-card { + width: 100%; + max-height: 220px; + } +} + diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 171426a6..dcb42a9f 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -1588,6 +1588,11 @@ "detailVulnId": "Vuln ID", "detailType": "Type", "detailTarget": "Target", + "detailProject": "Project", + "projectUnbound": "No project", + "projectBindHint": "Once bound, agents can list this finding under the project scope.", + "projectBindFailed": "Failed to update project binding", + "projectBindOk": "Project binding updated", "detailConversationId": "Conversation ID", "detailTaskId": "Task ID", "detailTaskQueueId": "Task queue ID", @@ -2161,6 +2166,9 @@ "add": "Add" }, "vulnerabilityModal": { + "project": "Project", + "projectNone": "(Unbound)", + "projectHint": "Bound findings appear in list_vulnerabilities for that project; leave empty to infer from the conversation when possible.", "conversationId": "Conversation ID", "conversationIdPlaceholder": "Enter conversation ID", "conversationTag": "Conversation tag", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index c456c3f1..4f539a28 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -1577,6 +1577,11 @@ "detailVulnId": "漏洞ID", "detailType": "类型", "detailTarget": "目标", + "detailProject": "所属项目", + "projectUnbound": "未绑定项目", + "projectBindHint": "绑定后 Agent 可在项目范围内查询到该漏洞", + "projectBindFailed": "绑定项目失败", + "projectBindOk": "已更新项目绑定", "detailConversationId": "会话ID", "detailTaskId": "任务ID", "detailTaskQueueId": "任务队列ID", @@ -2150,6 +2155,9 @@ "add": "添加" }, "vulnerabilityModal": { + "project": "所属项目", + "projectNone": "(未绑定)", + "projectHint": "绑定后 Agent 在项目范围内可通过 list_vulnerabilities 看到本条记录;留空则尝试从会话自动关联。", "conversationId": "会话ID", "conversationIdPlaceholder": "输入会话ID", "conversationTag": "对话标签", diff --git a/web/static/js/chat.js b/web/static/js/chat.js index eff6e720..e495d111 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -646,6 +646,9 @@ function toggleAgentModePanel() { if (typeof closeRoleSelectionPanel === 'function') { closeRoleSelectionPanel(); } + if (typeof closeChatProjectPanel === 'function') { + closeChatProjectPanel(); + } panel.style.display = 'flex'; btn.classList.add('active'); btn.setAttribute('aria-expanded', 'true'); @@ -897,6 +900,10 @@ async function sendMessage() { conversationId: currentConversationId, role: typeof getCurrentRole === 'function' ? getCurrentRole() : '' }; + if (!currentConversationId && typeof getActiveProjectId === 'function') { + const pid = getActiveProjectId(); + if (pid) body.projectId = pid; + } const hitlCfg = readHitlConfigFromForm(); if (normalizeHitlMode(hitlCfg.mode) !== HITL_MODE_OFF) { const sensitiveTools = hitlToolsSplitToArray(hitlCfg.sensitiveTools || ''); @@ -2900,10 +2907,14 @@ async function startNewConversation() { } currentConversationId = null; + window._loadedConversationProjectId = ''; try { window.currentConversationId = ''; } catch (e) { /* ignore */ } currentConversationGroupId = null; // 新对话不属于任何分组 + if (typeof refreshChatProjectSelector === 'function') { + refreshChatProjectSelector(); + } document.getElementById('chat-messages').innerHTML = ''; const readyMsgNew = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。'; addMessage('assistant', readyMsgNew, null, null, null, { systemReadyMessage: true }); @@ -3158,9 +3169,13 @@ async function loadConversation(conversationId) { // 更新当前对话ID currentConversationId = conversationId; + window._loadedConversationProjectId = conversation.projectId || conversation.project_id || ''; try { window.currentConversationId = conversationId; } catch (e) { /* ignore */ } + if (typeof refreshChatProjectSelector === 'function') { + refreshChatProjectSelector(); + } if (typeof window.syncHitlConfigFromServer === 'function') { await window.syncHitlConfigFromServer(conversationId); } else { diff --git a/web/static/js/info-collect.js b/web/static/js/info-collect.js index a0d0cfa1..fe3301ab 100644 --- a/web/static/js/info-collect.js +++ b/web/static/js/info-collect.js @@ -1028,7 +1028,12 @@ async function batchScanSelectedFofaRows() { const resp = await apiFetch('/api/batch-tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, tasks, role }) + body: JSON.stringify({ + title, + tasks, + role, + projectId: typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : '', + }) }); const result = await resp.json().catch(() => ({})); if (!resp.ok) { diff --git a/web/static/js/projects.js b/web/static/js/projects.js new file mode 100644 index 00000000..a6bd8850 --- /dev/null +++ b/web/static/js/projects.js @@ -0,0 +1,907 @@ +/** + * 项目管理与事实黑板 + */ +let projectsCache = []; +let projectsCacheAll = []; +let currentProjectId = null; +let currentProjectTab = 'facts'; +const projectNameById = {}; +let _projectsListReady = false; +let _projectsFetchPromise = null; + +const PROJECT_ACTIVE_KEY = 'cyberstrike.activeProjectId'; + +function getActiveProjectId() { + try { + return localStorage.getItem(PROJECT_ACTIVE_KEY) || ''; + } catch (e) { + return ''; + } +} + +function setActiveProjectId(id) { + try { + if (id) localStorage.setItem(PROJECT_ACTIVE_KEY, id); + else localStorage.removeItem(PROJECT_ACTIVE_KEY); + } catch (e) { /* ignore */ } +} + +function rebuildProjectNameMap(list) { + Object.keys(projectNameById).forEach((k) => delete projectNameById[k]); + (list || []).forEach((p) => { + if (p && p.id) projectNameById[p.id] = p.name || p.id; + }); +} + +async function fetchProjectsList(includeArchived) { + const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked; + const url = showArchived ? '/api/projects?limit=200' : '/api/projects?status=active&limit=200'; + const res = await apiFetch(url); + if (!res.ok) throw new Error('加载项目失败'); + const data = await res.json(); + projectsCache = Array.isArray(data) ? data : []; + rebuildProjectNameMap(projectsCache); + _projectsListReady = true; + return projectsCache; +} + +/** 对话页等项目选择器:确保列表已拉取(去重并发请求) */ +async function ensureProjectsLoaded(force) { + if (!force && _projectsListReady) return projectsCache; + if (!force && _projectsFetchPromise) return _projectsFetchPromise; + _projectsFetchPromise = fetchProjectsList(false) + .catch((e) => { + _projectsListReady = false; + throw e; + }) + .finally(() => { + _projectsFetchPromise = null; + }); + return _projectsFetchPromise; +} + +function prefetchProjectsForChat() { + ensureProjectsLoaded().catch(() => {}); +} + +function getProjectName(id) { + return projectNameById[id] || id || ''; +} + +function initProjectsModalEscape() { + if (window._projectsModalEscapeBound) return; + window._projectsModalEscapeBound = true; + document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + if (document.getElementById('project-modal')?.style.display === 'flex') closeProjectModal(); + else if (document.getElementById('fact-modal')?.style.display === 'flex') closeFactModal(); + else if (document.getElementById('fact-detail-modal')?.style.display === 'flex') closeFactDetailModal(); + }); +} + +async function initProjectsPage() { + const page = document.getElementById('page-projects'); + if (!page || page.style.display === 'none') return; + initProjectsModalEscape(); + updateProjectsDetailVisibility(); + await loadProjectsList(); + if (!currentProjectId && projectsCache.length) { + const fromHash = new URLSearchParams(window.location.hash.split('?')[1] || '').get('id'); + currentProjectId = fromHash || projectsCache[0].id; + } + renderProjectsSidebar(); + if (currentProjectId) { + await selectProject(currentProjectId); + } +} + +async function loadProjectsList() { + await fetchProjectsList(); + renderProjectsSidebar(); + if (typeof refreshChatProjectSelector === 'function') { + refreshChatProjectSelector(); + } + if (typeof refreshVulnerabilityProjectFilter === 'function') { + refreshVulnerabilityProjectFilter(); + } +} + +function projectInitial(name) { + const s = (name || 'P').trim(); + return s ? s.charAt(0).toUpperCase() : 'P'; +} + +function updateProjectsDetailVisibility() { + const main = document.getElementById('projects-detail-main'); + const placeholder = document.getElementById('projects-detail-placeholder'); + const inner = document.getElementById('projects-detail-inner'); + const show = !!currentProjectId; + if (main) main.classList.toggle('has-project', show); + if (placeholder) placeholder.hidden = show; + if (inner) inner.hidden = !show; +} + +function updateProjectsListCount() { + const el = document.getElementById('projects-list-count'); + if (el) el.textContent = String(projectsCache.length); +} + +function formatConfidenceBadge(confidence) { + const c = (confidence || '').toLowerCase(); + let cls = 'projects-confidence--tentative'; + let label = c || '—'; + if (c === 'confirmed') { + cls = 'projects-confidence--confirmed'; + label = '已确认'; + } else if (c === 'deprecated') { + cls = 'projects-confidence--deprecated'; + label = '已废弃'; + } else if (c === 'tentative') { + label = '待确认'; + } + return `${escapeHtml(label)}`; +} + +function renderProjectFactActions(keyEsc, idEsc) { + return `
+ + + + +
`; +} + +function formatSeverityBadge(severity) { + const s = (severity || 'info').toLowerCase(); + const cls = 'projects-severity--' + (['critical', 'high', 'medium', 'low', 'info'].includes(s) ? s : 'info'); + return `${escapeHtml(severity || '—')}`; +} + +function getProjectsListFilter() { + return (document.getElementById('projects-list-search')?.value || '').trim().toLowerCase(); +} + +function filterProjectsList() { + renderProjectsSidebar(); +} + +function renderProjectsSidebar() { + const el = document.getElementById('projects-list'); + if (!el) return; + updateProjectsListCount(); + const q = getProjectsListFilter(); + const list = q + ? projectsCache.filter((p) => (p.name || '').toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q)) + : projectsCache; + if (!projectsCache.length) { + el.innerHTML = + '
暂无项目
'; + updateProjectsDetailVisibility(); + return; + } + if (!list.length) { + el.innerHTML = '
无匹配项目
'; + updateProjectsDetailVisibility(); + return; + } + el.innerHTML = list.map((p) => { + const active = p.id === currentProjectId ? ' is-active' : ''; + const archived = p.status === 'archived' ? ' is-archived' : ''; + const badges = [ + p.pinned ? '置顶' : '', + p.status === 'archived' ? '归档' : '', + ].join(''); + return `
+
+
${escapeHtml(p.name)}${badges}
+
${formatProjectTime(p.updated_at)}
+
+
`; + }).join(''); + updateProjectsDetailVisibility(); +} + +function updateProjectStatusPill(status) { + const el = document.getElementById('projects-detail-status'); + if (!el) return; + const archived = status === 'archived'; + el.textContent = archived ? '已归档' : '进行中'; + el.className = 'projects-status-pill ' + (archived ? 'projects-status-pill--archived' : 'projects-status-pill--active'); +} + +function updateProjectStats(factCount, vulnCount) { + 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} 个漏洞`; +} + +async function selectProject(id) { + currentProjectId = id; + renderProjectsSidebar(); + updateProjectsDetailVisibility(); + try { + const res = await apiFetch(`/api/projects/${id}`); + if (!res.ok) throw new Error('项目不存在'); + const p = await res.json(); + const titleEl = document.getElementById('projects-detail-title'); + if (titleEl) titleEl.textContent = p.name || '项目'; + document.getElementById('project-edit-name').value = p.name || ''; + document.getElementById('project-edit-description').value = p.description || ''; + document.getElementById('project-edit-scope').value = p.scope_json || ''; + const statusEl = document.getElementById('project-edit-status'); + if (statusEl) statusEl.value = p.status || 'active'; + updateProjectStatusPill(p.status || 'active'); + const metaEl = document.getElementById('projects-detail-meta'); + if (metaEl) metaEl.textContent = `更新于 ${formatProjectTime(p.updated_at)}`; + const descEl = document.getElementById('projects-detail-desc'); + if (descEl) { + const desc = (p.description || '').trim(); + if (desc) { + descEl.textContent = desc; + descEl.hidden = false; + } else { + descEl.textContent = ''; + descEl.hidden = true; + } + } + projectNameById[p.id] = p.name || p.id; + } catch (e) { + console.warn(e); + } + refreshProjectHeaderStats(); + switchProjectTab(currentProjectTab); +} + +function switchProjectTab(tab) { + currentProjectTab = tab; + ['facts', '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 === 'vulns') loadProjectVulnerabilities(); +} + +async function loadProjectFacts() { + const tbody = document.getElementById('project-facts-tbody'); + if (!tbody || !currentProjectId) return; + tbody.innerHTML = '加载中…'; + const res = await apiFetch(`/api/projects/${currentProjectId}/facts?limit=200`); + if (!res.ok) { + tbody.innerHTML = '加载失败'; + return; + } + const facts = await res.json(); + if (!facts.length) { + tbody.innerHTML = '暂无事实,点击「添加事实」或由 Agent 自动写入'; + refreshProjectHeaderStats(); + return; + } + tbody.innerHTML = facts.map((f) => { + const keyEsc = escapeHtml(f.fact_key); + const idEsc = escapeHtml(f.id); + return ` + ${keyEsc} + ${escapeHtml(f.category)} + ${escapeHtml(f.summary)} + ${formatConfidenceBadge(f.confidence)} + ${formatProjectTime(f.updated_at, f.created_at)} + ${renderProjectFactActions(keyEsc, idEsc)} + `; + }).join(''); + refreshProjectHeaderStats(); +} + +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); + } catch (e) { + console.warn(e); + } +} + +let _factDetailKey = 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; + document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`; + document.getElementById('fact-detail-meta').textContent = + `分类: ${f.category} · 置信度: ${f.confidence} · 更新: ${formatProjectTime(f.updated_at, f.created_at)}` + + (f.related_vulnerability_id ? ` · 关联漏洞: ${f.related_vulnerability_id}` : ''); + document.getElementById('fact-detail-body').textContent = f.body || '(无 body)'; + openProjectsOverlay('fact-detail-modal'); +} + +function editFactFromDetail() { + const key = _factDetailKey; + closeFactDetailModal(); + if (key) showEditFactModal(key); +} + +function closeFactDetailModal() { + closeProjectsOverlay('fact-detail-modal'); + _factDetailKey = null; +} + +async function deprecateProjectFactByKey(factKey) { + if (!confirm(`将事实 ${factKey} 标记为 deprecated?`)) return; + const res = await apiFetch(`/api/projects/${currentProjectId}/facts/deprecate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fact_key: factKey }), + }); + if (!res.ok) return alert('操作失败'); + loadProjectFacts(); +} + +function openVulnerabilitiesForProject(projectId) { + const pid = projectId || currentProjectId; + if (!pid) return; + if (typeof switchPage === 'function') { + switchPage('vulnerabilities'); + } + if (typeof window.setVulnerabilityProjectFilter === 'function') { + window.setVulnerabilityProjectFilter(pid); + } else { + window.location.hash = `vulnerabilities?project_id=${encodeURIComponent(pid)}`; + } +} + +async function loadProjectVulnerabilities() { + const tbody = document.getElementById('project-vulns-tbody'); + if (!tbody || !currentProjectId) return; + tbody.innerHTML = '加载中…'; + const res = await apiFetch(`/api/vulnerabilities?project_id=${encodeURIComponent(currentProjectId)}&limit=100`); + if (!res.ok) { + tbody.innerHTML = '加载失败'; + return; + } + const data = await res.json(); + const items = data.Vulnerabilities || data.vulnerabilities || data.items || []; + if (!items.length) { + tbody.innerHTML = '本项目暂无漏洞记录'; + refreshProjectHeaderStats(); + return; + } + tbody.innerHTML = items.map((v) => { + const idEsc = escapeHtml(v.id); + return ` + ${escapeHtml(v.title)} + ${formatSeverityBadge(v.severity)} + ${escapeHtml(v.status)} + +
+ +
+ + `; + }).join(''); + refreshProjectHeaderStats(); +} + +function openVulnerabilityDetail(vulnId) { + openVulnerabilitiesForProject(currentProjectId); + if (typeof window.setVulnerabilityIdFilter === 'function') { + setTimeout(() => window.setVulnerabilityIdFilter(vulnId), 300); + } +} + +function openProjectsOverlay(id) { + const el = document.getElementById(id); + if (!el) return; + el.style.display = 'flex'; + document.body.classList.add('projects-modal-open'); + const focusTarget = el.querySelector('input.form-input, textarea.form-input, select.form-input'); + if (focusTarget) { + setTimeout(() => focusTarget.focus(), 80); + } +} + +function closeProjectsOverlay(id) { + const el = document.getElementById(id); + if (el) el.style.display = 'none'; + const anyOpen = document.querySelector('.projects-modal-overlay[style*="flex"]'); + if (!anyOpen) document.body.classList.remove('projects-modal-open'); +} + +function showNewProjectModal() { + document.getElementById('project-modal-title').textContent = '新建项目'; + const sub = document.getElementById('project-modal-subtitle'); + if (sub) sub.textContent = '创建后可绑定对话,跨会话共享事实黑板'; + const submitBtn = document.getElementById('project-modal-submit-btn'); + if (submitBtn) submitBtn.textContent = '创建项目'; + document.getElementById('project-modal-name').value = ''; + document.getElementById('project-modal-description').value = ''; + window._projectModalEditId = null; + openProjectsOverlay('project-modal'); +} + +async function saveProjectModal() { + const name = document.getElementById('project-modal-name').value.trim(); + if (!name) return alert('请输入项目名称'); + const body = { + name, + description: document.getElementById('project-modal-description').value.trim(), + }; + const editId = window._projectModalEditId; + const res = editId + ? await apiFetch(`/api/projects/${editId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) + : await apiFetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + alert(err.error || '保存失败'); + return; + } + closeProjectModal(); + const saved = await res.json(); + await loadProjectsList(); + if (saved.id) await selectProject(saved.id); +} + +function closeProjectModal() { + closeProjectsOverlay('project-modal'); +} + +function formatProjectScopeJson() { + const el = document.getElementById('project-edit-scope'); + if (!el) return; + const raw = el.value.trim(); + if (!raw) return; + try { + el.value = JSON.stringify(JSON.parse(raw), null, 2); + } catch (e) { + alert('JSON 格式无效:' + (e.message || String(e))); + } +} + +function insertProjectScopeExample() { + const el = document.getElementById('project-edit-scope'); + if (!el) return; + const example = { + targets: ['https://example.com'], + exclude: ['*.cdn.example.com'], + notes: '仅授权 Web 应用层测试', + }; + el.value = JSON.stringify(example, null, 2); + el.focus(); +} + +async function saveProjectSettings() { + if (!currentProjectId) return; + const scopeRaw = document.getElementById('project-edit-scope').value.trim(); + if (scopeRaw) { + try { + JSON.parse(scopeRaw); + } catch (e) { + alert('测试范围 JSON 无效,请先修正或点击「格式化」:' + (e.message || String(e))); + return; + } + } + const body = { + name: document.getElementById('project-edit-name').value.trim(), + description: document.getElementById('project-edit-description').value.trim(), + scope_json: scopeRaw, + status: document.getElementById('project-edit-status')?.value || 'active', + }; + const res = await apiFetch(`/api/projects/${currentProjectId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) return alert('保存失败'); + await loadProjectsList(); + await selectProject(currentProjectId); + alert('已保存'); +} + +async function archiveCurrentProject() { + if (!currentProjectId) return; + const statusEl = document.getElementById('project-edit-status'); + const cur = statusEl?.value || 'active'; + const next = cur === 'archived' ? 'active' : 'archived'; + if (!confirm(next === 'archived' ? '归档后默认不再出现在活跃列表,是否继续?' : '恢复为 active?')) return; + const res = await apiFetch(`/api/projects/${currentProjectId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: next }), + }); + if (!res.ok) return alert('操作失败'); + await loadProjectsList(); + await selectProject(currentProjectId); +} + +async function deleteCurrentProject() { + if (!currentProjectId || !confirm('确定删除该项目?事实将一并删除,对话将解除绑定。')) return; + const deletedId = currentProjectId; + const deletedIndex = projectsCache.findIndex((p) => p.id === deletedId); + const res = await apiFetch(`/api/projects/${deletedId}`, { method: 'DELETE' }); + if (!res.ok) return alert('删除失败'); + if (getActiveProjectId() === deletedId) setActiveProjectId(''); + currentProjectId = null; + await loadProjectsList(); + if (projectsCache.length) { + const nextIndex = Math.min(deletedIndex >= 0 ? deletedIndex : 0, projectsCache.length - 1); + await selectProject(projectsCache[nextIndex].id); + } else { + updateProjectsDetailVisibility(); + } +} + +function resetFactModalForm() { + window._factModalEditId = null; + const keyEl = document.getElementById('fact-modal-key'); + if (keyEl) keyEl.disabled = false; + document.getElementById('fact-modal-title').textContent = '添加事实'; + document.getElementById('fact-modal-submit-btn').textContent = '保存事实'; + document.getElementById('fact-modal-key').value = ''; + document.getElementById('fact-modal-category').value = 'note'; + document.getElementById('fact-modal-summary').value = ''; + document.getElementById('fact-modal-body').value = ''; + document.getElementById('fact-modal-confidence').value = 'tentative'; + const rel = document.getElementById('fact-modal-related-vuln'); + if (rel) rel.value = ''; +} + +function fillFactModalForm(f) { + window._factModalEditId = f.id; + document.getElementById('fact-modal-title').textContent = '编辑事实'; + document.getElementById('fact-modal-submit-btn').textContent = '保存修改'; + document.getElementById('fact-modal-key').value = f.fact_key || ''; + document.getElementById('fact-modal-category').value = f.category || 'note'; + document.getElementById('fact-modal-summary').value = f.summary || ''; + document.getElementById('fact-modal-body').value = f.body || ''; + const conf = (f.confidence || 'tentative').toLowerCase(); + const confEl = document.getElementById('fact-modal-confidence'); + if (confEl) { + const allowed = ['tentative', 'confirmed', 'deprecated']; + confEl.value = allowed.includes(conf) ? conf : 'tentative'; + } + const rel = document.getElementById('fact-modal-related-vuln'); + if (rel) rel.value = f.related_vulnerability_id || ''; +} + +function showAddFactModal() { + if (!currentProjectId) return alert('请先选择项目'); + resetFactModalForm(); + openProjectsOverlay('fact-modal'); +} + +async function showEditFactModal(factKey) { + if (!currentProjectId) return alert('请先选择项目'); + const res = await apiFetch( + `/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`, + ); + if (!res.ok) return alert('加载事实失败'); + const f = await res.json(); + resetFactModalForm(); + fillFactModalForm(f); + openProjectsOverlay('fact-modal'); +} + +function closeFactModal() { + closeProjectsOverlay('fact-modal'); + resetFactModalForm(); +} + +async function saveFactModal() { + const fact_key = document.getElementById('fact-modal-key').value.trim(); + const summary = document.getElementById('fact-modal-summary').value.trim(); + if (!fact_key || !summary) return alert('fact_key 与 summary 必填'); + const payload = { + fact_key, + category: document.getElementById('fact-modal-category').value.trim() || 'note', + summary, + body: document.getElementById('fact-modal-body').value, + confidence: document.getElementById('fact-modal-confidence').value, + related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '', + }; + const editId = window._factModalEditId; + const res = editId + ? await apiFetch(`/api/projects/${currentProjectId}/facts/${editId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + : await apiFetch(`/api/projects/${currentProjectId}/facts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return alert(err.error || '保存失败'); + } + closeFactModal(); + loadProjectFacts(); +} + +async function deleteProjectFact(id) { + if (!confirm('删除该事实?')) return; + await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' }); + loadProjectFacts(); +} + +function parseProjectDate(t) { + if (t == null || t === '') return null; + if (typeof t === 'number' && Number.isFinite(t)) { + const d = new Date(t); + return isNaN(d.getTime()) || d.getFullYear() < 2000 ? null : d; + } + let s = String(t).trim(); + if (!s || s.startsWith('0001-01-01')) return null; + let d = new Date(s); + if (!isNaN(d.getTime()) && d.getFullYear() >= 2000) return d; + const m = s.match( + /^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:([Zz]|([+-])(\d{2}):?(\d{2}))?)?$/, + ); + if (m) { + const ms = m[7] ? parseInt(String(m[7]).slice(0, 3).padEnd(3, '0'), 10) : 0; + let offMin = 0; + if (m[8] && m[9] && m[10]) { + offMin = parseInt(m[10], 10) * 60 + parseInt(m[11] || '0', 10); + if (m[9] === '-') offMin = -offMin; + } + d = new Date( + Date.UTC( + parseInt(m[1], 10), + parseInt(m[2], 10) - 1, + parseInt(m[3], 10), + parseInt(m[4], 10), + parseInt(m[5], 10), + parseInt(m[6], 10), + ms, + ) - offMin * 60 * 1000, + ); + if (!isNaN(d.getTime()) && d.getFullYear() >= 2000) return d; + } + return null; +} + +function formatProjectTime(t, fallback) { + const d = parseProjectDate(t) || (fallback != null ? parseProjectDate(fallback) : null); + if (!d) return '尚未更新'; + const now = Date.now(); + const diff = now - d.getTime(); + if (diff < 60000) return '刚刚'; + if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`; + if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`; + return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + +function escapeHtml(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function getChatProjectSelection() { + const convId = window.currentConversationId; + if (convId) { + return window._loadedConversationProjectId || ''; + } + return getActiveProjectId(); +} + +function updateChatProjectButtonLabel() { + const textEl = document.getElementById('chat-project-text'); + if (!textEl) return; + const id = getChatProjectSelection(); + textEl.textContent = id ? getProjectName(id) || id : '无项目'; +} + +function renderChatProjectPanelList() { + const list = document.getElementById('chat-project-list'); + if (!list) return; + const selected = getChatProjectSelection(); + const activeProjects = projectsCache.filter((p) => p.status !== 'archived'); + const items = [{ id: '', name: '无项目', description: '不绑定项目黑板' }, ...activeProjects]; + if (!items.length) { + list.innerHTML = '
暂无项目,可在「项目管理」中创建
'; + return; + } + list.innerHTML = ''; + items.forEach((p) => { + const isNone = !p.id; + const isSelected = isNone ? !selected : selected === p.id; + const desc = isNone + ? (p.description || '') + : (p.description || '').trim().slice(0, 80) || '共享事实黑板'; + const projectId = p.id || ''; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : ''); + btn.setAttribute('role', 'option'); + btn.onclick = () => { + selectChatProject(projectId); + }; + btn.innerHTML = ` +
${isNone ? '—' : '📁'}
+
+
${escapeHtml(p.name || '未命名')}
+
${escapeHtml(desc)}
+
+ ${isSelected ? '
' : ''} + `; + list.appendChild(btn); + }); +} + +async function renderChatProjectPanel() { + const list = document.getElementById('chat-project-list'); + if (!list) return; + list.innerHTML = '
加载中…
'; + try { + await ensureProjectsLoaded(); + } catch (e) { + console.warn(e); + list.innerHTML = '
加载失败,请稍后重试
'; + return; + } + renderChatProjectPanelList(); +} + +function closeChatProjectPanel() { + const panel = document.getElementById('chat-project-panel'); + const btn = document.getElementById('chat-project-btn'); + if (panel) panel.style.display = 'none'; + if (btn) { + btn.classList.remove('active'); + btn.setAttribute('aria-expanded', 'false'); + } +} + +async function toggleChatProjectPanel() { + const panel = document.getElementById('chat-project-panel'); + const btn = document.getElementById('chat-project-btn'); + if (!panel) return; + const isHidden = panel.style.display === 'none' || !panel.style.display; + if (!isHidden) { + closeChatProjectPanel(); + return; + } + if (typeof closeRoleSelectionPanel === 'function') closeRoleSelectionPanel(); + if (typeof closeAgentModePanel === 'function') closeAgentModePanel(); + if (typeof closeChatReasoningPanel === 'function') closeChatReasoningPanel(); + panel.style.display = 'flex'; + if (btn) { + btn.classList.add('active'); + btn.setAttribute('aria-expanded', 'true'); + } + await renderChatProjectPanel(); +} + +async function selectChatProject(projectId) { + closeChatProjectPanel(); + await applyChatProjectSelection(projectId || ''); +} + +async function applyChatProjectSelection(projectId) { + const prev = getChatProjectSelection(); + if (projectId === prev) { + updateChatProjectButtonLabel(); + return; + } + if (window.currentConversationId) { + try { + const res = await apiFetch(`/api/conversations/${encodeURIComponent(window.currentConversationId)}/project`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ projectId }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || res.statusText); + } + window._loadedConversationProjectId = projectId; + if (typeof showNotification === 'function') { + showNotification(projectId ? '已绑定项目' : '已解除项目绑定', 'success'); + } + } catch (e) { + console.error(e); + alert('更新项目绑定失败: ' + (e.message || e)); + updateChatProjectButtonLabel(); + return; + } + } else { + setActiveProjectId(projectId); + } + updateChatProjectButtonLabel(); +} + +/** 对话页项目选择器:同步按钮文案;若浮层已打开则刷新列表 */ +async function refreshChatProjectSelector() { + if (!document.getElementById('chat-project-btn')) return; + try { + await ensureProjectsLoaded(); + } catch (e) { + console.warn(e); + } + updateChatProjectButtonLabel(); + const panel = document.getElementById('chat-project-panel'); + if (panel && panel.style.display === 'flex') { + renderChatProjectPanelList(); + } +} + +async function onChatProjectChange() { + /* 兼容旧调用;新 UI 使用 selectChatProject */ + await applyChatProjectSelection(getChatProjectSelection()); +} + +function initChatProjectSelector() { + if (window._chatProjectSelectorInited) return; + window._chatProjectSelectorInited = true; + prefetchProjectsForChat(); + updateChatProjectButtonLabel(); + document.addEventListener('click', (e) => { + const panel = document.getElementById('chat-project-panel'); + const wrapper = document.querySelector('.project-selector-wrapper'); + if (!panel || panel.style.display === 'none' || !panel.style.display) return; + if (!wrapper?.contains(e.target)) { + closeChatProjectPanel(); + } + }); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initChatProjectSelector); +} else { + initChatProjectSelector(); +} + +window.initProjectsPage = initProjectsPage; +window.showNewProjectModal = showNewProjectModal; +window.saveProjectModal = saveProjectModal; +window.closeProjectModal = closeProjectModal; +window.selectProject = selectProject; +window.switchProjectTab = switchProjectTab; +window.showAddFactModal = showAddFactModal; +window.showEditFactModal = showEditFactModal; +window.editFactFromDetail = editFactFromDetail; +window.saveFactModal = saveFactModal; +window.closeFactModal = closeFactModal; +window.closeFactDetailModal = closeFactDetailModal; +window.saveProjectSettings = saveProjectSettings; +window.archiveCurrentProject = archiveCurrentProject; +window.deleteCurrentProject = deleteCurrentProject; +window.refreshChatProjectSelector = refreshChatProjectSelector; +window.onChatProjectChange = onChatProjectChange; +window.toggleChatProjectPanel = toggleChatProjectPanel; +window.closeChatProjectPanel = closeChatProjectPanel; +window.selectChatProject = selectChatProject; +window.prefetchProjectsForChat = prefetchProjectsForChat; +window.getActiveProjectId = getActiveProjectId; +window.getProjectName = getProjectName; +window.viewProjectFactBody = viewProjectFactBody; +window.deprecateProjectFactByKey = deprecateProjectFactByKey; +window.openVulnerabilitiesForProject = openVulnerabilitiesForProject; +window.openVulnerabilityDetail = openVulnerabilityDetail; +window.filterProjectsList = filterProjectsList; +window.rebuildProjectNameMap = rebuildProjectNameMap; +window.projectNameById = projectNameById; diff --git a/web/static/js/roles.js b/web/static/js/roles.js index 890c1a6b..7b8506b1 100644 --- a/web/static/js/roles.js +++ b/web/static/js/roles.js @@ -244,30 +244,46 @@ function selectRole(roleName) { renderRoleSelectionSidebar(); // 重新渲染以更新选中状态 } +function getChatRoleSelectorWrapper() { + return document.getElementById('role-selector-wrapper') + || document.getElementById('role-selector-btn')?.closest('.role-selector-wrapper:not(.project-selector-wrapper)'); +} + +function isRoleSelectionPanelOpen() { + const panel = document.getElementById('role-selection-panel'); + if (!panel) return false; + return panel.style.display !== 'none' && panel.style.display !== ''; +} + // 切换角色选择面板显示/隐藏 function toggleRoleSelectionPanel() { const panel = document.getElementById('role-selection-panel'); const roleSelectorBtn = document.getElementById('role-selector-btn'); if (!panel) return; - const isHidden = panel.style.display === 'none' || !panel.style.display; + const isHidden = !isRoleSelectionPanelOpen(); if (isHidden) { if (typeof closeAgentModePanel === 'function') { closeAgentModePanel(); } + if (typeof closeChatProjectPanel === 'function') { + closeChatProjectPanel(); + } if (typeof closeChatReasoningPanel === 'function') { closeChatReasoningPanel(); } + renderRoleSelectionSidebar(); panel.style.display = 'flex'; // 使用flex布局 // 添加打开状态的视觉反馈 if (roleSelectorBtn) { roleSelectorBtn.classList.add('active'); + roleSelectorBtn.setAttribute('aria-expanded', 'true'); } // 确保面板渲染后再检查位置 setTimeout(() => { - const wrapper = document.querySelector('.role-selector-wrapper'); + const wrapper = getChatRoleSelectorWrapper(); if (wrapper) { const rect = wrapper.getBoundingClientRect(); const panelHeight = panel.offsetHeight || 400; @@ -281,11 +297,7 @@ function toggleRoleSelectionPanel() { } }, 10); } else { - panel.style.display = 'none'; - // 移除打开状态的视觉反馈 - if (roleSelectorBtn) { - roleSelectorBtn.classList.remove('active'); - } + closeRoleSelectionPanel(); } } @@ -298,6 +310,7 @@ function closeRoleSelectionPanel() { } if (roleSelectorBtn) { roleSelectorBtn.classList.remove('active'); + roleSelectorBtn.setAttribute('aria-expanded', 'false'); } } @@ -1568,9 +1581,9 @@ async function deleteRole(roleName) { } // 在页面切换时初始化角色列表 -if (typeof switchPage === 'function') { - const originalSwitchPage = switchPage; - switchPage = function(page) { +if (typeof window.switchPage === 'function') { + const originalSwitchPage = window.switchPage; + window.switchPage = function(page) { originalSwitchPage(page); if (page === 'roles-management') { loadRoles().then(() => renderRolesList()); @@ -1590,11 +1603,9 @@ document.addEventListener('click', (e) => { closeRoleModal(); } - // 点击角色选择面板外部关闭面板(但不包括角色选择按钮和面板本身) - const roleSelectionPanel = document.getElementById('role-selection-panel'); - const roleSelectorWrapper = document.querySelector('.role-selector-wrapper'); - if (roleSelectionPanel && roleSelectionPanel.style.display !== 'none' && roleSelectionPanel.style.display) { - // 检查点击是否在面板或包装器上 + // 点击角色选择面板外部关闭(须用 #role-selector-wrapper,勿用 .role-selector-wrapper:项目选择器也带该类) + if (isRoleSelectionPanelOpen()) { + const roleSelectorWrapper = getChatRoleSelectorWrapper(); if (!roleSelectorWrapper?.contains(e.target)) { closeRoleSelectionPanel(); } diff --git a/web/static/js/router.js b/web/static/js/router.js index c5ae75d4..1bae4cb4 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -25,6 +25,13 @@ function scheduleChatConversationFromHash(delayMs) { } const params = new URLSearchParams(hashParts.slice(1).join('?')); const conversationId = params.get('conversation'); + const projectId = params.get('project'); + if (projectId && typeof setActiveProjectId === 'function') { + setActiveProjectId(projectId); + if (typeof refreshChatProjectSelector === 'function') { + refreshChatProjectSelector(); + } + } if (!conversationId) { return; } @@ -50,7 +57,7 @@ function initRouter() { if (hash) { const hashParts = hash.split('?'); const pageId = hashParts[0]; - if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { + if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) { switchPage(pageId); if (pageId === 'chat') { scheduleChatConversationFromHash(500); @@ -187,6 +194,24 @@ function updateNavState(pageId) { } } +/** 读取侧栏子菜单项(仅 .nav-submenu 内,避免误匹配) */ +function getNavSubmenuItems(navItem) { + if (!navItem) return []; + const submenu = navItem.querySelector('.nav-submenu'); + if (!submenu) return []; + return Array.from(submenu.querySelectorAll('.nav-submenu-item')); +} + +/** 仅一个子页时直接进入,避免展开后菜单在侧栏底部不可见 */ +function navigateSingleSubmenuPage(navItem) { + const items = getNavSubmenuItems(navItem); + if (items.length !== 1) return false; + const pageId = items[0].getAttribute('data-page'); + if (!pageId) return false; + switchPage(pageId); + return true; +} + // 切换子菜单 function toggleSubmenu(menuId) { const sidebar = document.getElementById('main-sidebar'); @@ -194,24 +219,50 @@ function toggleSubmenu(menuId) { if (!navItem) return; + const collapsed = sidebar && sidebar.classList.contains('collapsed'); + // 检查侧边栏是否折叠 - if (sidebar && sidebar.classList.contains('collapsed')) { + if (collapsed) { // 折叠状态下显示弹出菜单 showSubmenuPopup(navItem, menuId); - } else { - // 展开状态下正常切换子菜单 - navItem.classList.toggle('expanded'); + return; + } + + // 展开侧栏且仅一个子项(角色、Agents 等):单击直接进入,无需再点二级菜单 + if (navigateSingleSubmenuPage(navItem)) { + return; + } + + // 展开状态下切换子菜单,并滚入视口以便看到子项 + const willExpand = !navItem.classList.contains('expanded'); + navItem.classList.toggle('expanded'); + if (willExpand) { + requestAnimationFrame(() => { + navItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + const items = getNavSubmenuItems(navItem); + const last = items[items.length - 1]; + if (last) { + last.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }); } } window.toggleSubmenu = toggleSubmenu; // 显示子菜单弹出框 function showSubmenuPopup(navItem, menuId) { - // 移除其他已打开的弹出菜单 const existingPopup = document.querySelector('.submenu-popup'); if (existingPopup) { + const sameMenu = existingPopup.dataset.menuId === menuId; existingPopup.remove(); - return; // 如果已经打开,点击时关闭 + // 再次点击同一项:仅关闭;点击另一项:继续打开新菜单 + if (sameMenu) { + return; + } + } + + if (navigateSingleSubmenuPage(navItem)) { + return; } const navItemContent = navItem.querySelector('.nav-item-content'); @@ -225,6 +276,7 @@ function showSubmenuPopup(navItem, menuId) { // 创建弹出菜单 const popup = document.createElement('div'); popup.className = 'submenu-popup'; + popup.dataset.menuId = menuId; popup.style.position = 'fixed'; popup.style.left = (rect.right + 8) + 'px'; popup.style.top = rect.top + 'px'; @@ -289,6 +341,12 @@ async function initPage(pageId) { case 'chat': // 恢复对话列表折叠状态(从其他页返回时保持用户选择) initConversationSidebarState(); + if (typeof prefetchProjectsForChat === 'function') { + prefetchProjectsForChat(); + } + if (typeof refreshChatProjectSelector === 'function') { + refreshChatProjectSelector(); + } break; case 'hitl': if (typeof refreshHitlPending === 'function') { @@ -348,6 +406,11 @@ async function initPage(pageId) { }); } break; + case 'projects': + if (typeof initProjectsPage === 'function') { + initProjectsPage(); + } + break; case 'vulnerabilities': // 初始化漏洞管理页面 if (typeof initVulnerabilityPage === 'function') { diff --git a/web/static/js/tasks.js b/web/static/js/tasks.js index 70adbb84..69458742 100644 --- a/web/static/js/tasks.js +++ b/web/static/js/tasks.js @@ -979,7 +979,16 @@ async function createBatchQueue() { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ title, tasks, role, agentMode, scheduleMode, cronExpr, executeNow }), + body: JSON.stringify({ + title, + tasks, + role, + agentMode, + scheduleMode, + cronExpr, + executeNow, + projectId: typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : '', + }), }); if (!response.ok) { diff --git a/web/static/js/vulnerability.js b/web/static/js/vulnerability.js index 91e9007a..28b4a371 100644 --- a/web/static/js/vulnerability.js +++ b/web/static/js/vulnerability.js @@ -48,6 +48,7 @@ let currentVulnerabilityId = null; let vulnerabilityFilters = { q: '', id: '', + project_id: '', conversation_id: '', task_id: '', conversation_tag: '', @@ -77,6 +78,7 @@ const VULN_FILTER_CHIP_FIELDS = [ { key: 'id', labelKey: 'vulnerabilityPage.vulnId' }, { key: 'status', labelKey: null, format: 'status' }, { key: 'severity', labelKey: null, format: 'severity' }, + { key: 'project_id', labelKey: null }, { key: 'conversation_id', labelKey: 'vulnerabilityPage.conversationId' }, { key: 'task_id', labelKey: 'vulnerabilityPage.taskOrQueueId' }, { key: 'conversation_tag', labelKey: 'vulnerabilityPage.conversationTag' }, @@ -98,13 +100,15 @@ function syncVulnerabilityFiltersFromLocationHash() { const st = (params.get('status') || '').trim(); const convTag = (params.get('conversation_tag') || '').trim(); const taskTag = (params.get('task_tag') || '').trim(); + const pid = (params.get('project_id') || '').trim(); const q = (params.get('q') || params.get('search') || '').trim(); - if (!vid && !cid && !tid && !sev && !st && !convTag && !taskTag && !q) { + if (!vid && !cid && !tid && !sev && !st && !convTag && !taskTag && !q && !pid) { return; } vulnerabilityFilters.q = ''; vulnerabilityFilters.id = ''; + vulnerabilityFilters.project_id = ''; vulnerabilityFilters.conversation_id = ''; vulnerabilityFilters.task_id = ''; vulnerabilityFilters.conversation_tag = ''; @@ -117,6 +121,7 @@ function syncVulnerabilityFiltersFromLocationHash() { const taskEl = document.getElementById('vulnerability-task-filter'); const convTagEl = document.getElementById('vulnerability-conversation-tag-filter'); const taskTagEl = document.getElementById('vulnerability-task-tag-filter'); + const projEl = document.getElementById('vulnerability-project-filter'); const sevEl = document.getElementById('vulnerability-severity-filter'); const stEl = document.getElementById('vulnerability-status-filter'); if (searchEl) searchEl.value = ''; @@ -125,6 +130,7 @@ function syncVulnerabilityFiltersFromLocationHash() { if (taskEl) taskEl.value = ''; if (convTagEl) convTagEl.value = ''; if (taskTagEl) taskTagEl.value = ''; + if (projEl) projEl.value = ''; if (sevEl) sevEl.value = ''; if (stEl) stEl.value = ''; @@ -132,6 +138,10 @@ function syncVulnerabilityFiltersFromLocationHash() { vulnerabilityFilters.q = q; if (searchEl) searchEl.value = q; } + if (pid) { + vulnerabilityFilters.project_id = pid; + if (projEl) projEl.value = pid; + } if (vid) { vulnerabilityFilters.id = vid; if (exactIdEl) exactIdEl.value = vid; @@ -167,12 +177,13 @@ function syncVulnerabilityFiltersFromLocationHash() { } // 初始化漏洞管理页面 -function initVulnerabilityPage() { +async function initVulnerabilityPage() { // 从localStorage加载每页条数设置 vulnerabilityPagination.pageSize = getVulnerabilityPageSize(); initVulnerabilityStatCards(); initVulnerabilityFilterPanel(); syncVulnerabilityFiltersFromLocationHash(); + await refreshVulnerabilityProjectFilter(); updateVulnerabilityFilterPanelState(); renderVulnerabilityFilterChips(); loadVulnerabilityFilterOptions(); @@ -224,6 +235,7 @@ function applyVulnerabilitySeverityFilter(severity) { function readVulnerabilityFiltersFromForm() { vulnerabilityFilters.q = (document.getElementById('vulnerability-search-filter')?.value || '').trim(); vulnerabilityFilters.id = (document.getElementById('vulnerability-exact-id-filter')?.value || '').trim(); + vulnerabilityFilters.project_id = (document.getElementById('vulnerability-project-filter')?.value || '').trim(); vulnerabilityFilters.conversation_id = (document.getElementById('vulnerability-conversation-filter')?.value || '').trim(); vulnerabilityFilters.task_id = (document.getElementById('vulnerability-task-filter')?.value || '').trim(); vulnerabilityFilters.conversation_tag = (document.getElementById('vulnerability-conversation-tag-filter')?.value || '').trim(); @@ -241,7 +253,7 @@ function hasVulnerabilityAdvancedFiltersActive() { function hasAnyVulnerabilityFilterActive() { const f = vulnerabilityFilters; return Boolean( - f.q || f.id || f.conversation_id || f.task_id || f.conversation_tag || f.task_tag || f.severity || f.status + f.q || f.id || f.project_id || f.conversation_id || f.task_id || f.conversation_tag || f.task_tag || f.severity || f.status ); } @@ -265,6 +277,7 @@ function updateVulnerabilityLocationHashFromFilters() { const pairs = [ ['q', f.q], ['id', f.id], + ['project_id', f.project_id], ['conversation_id', f.conversation_id], ['task_id', f.task_id], ['conversation_tag', f.conversation_tag], @@ -476,6 +489,10 @@ function updateVulnerabilityFilterPanelState() { function formatVulnerabilityFilterChipValue(key, value) { if (key === 'severity') return vulnSeverityLabel(value); if (key === 'status') return vulnStatusLabel(value); + if (key === 'project_id') { + const name = typeof getProjectName === 'function' ? getProjectName(value) : ''; + return name && name !== value ? name : value; + } return value; } @@ -489,7 +506,7 @@ function renderVulnerabilityFilterChips() { VULN_FILTER_CHIP_FIELDS.forEach(function (field) { const val = vulnerabilityFilters[field.key]; if (!val) return; - const label = field.labelKey ? vulnT(field.labelKey) : ''; + const label = field.labelKey ? vulnT(field.labelKey) : (field.key === 'project_id' ? '项目' : ''); const displayVal = formatVulnerabilityFilterChipValue(field.key, val); const text = label ? label + ': ' + displayVal : displayVal; chips.push({ key: field.key, text: text }); @@ -529,6 +546,7 @@ function removeVulnerabilityFilterByKey(key) { task_id: 'vulnerability-task-filter', conversation_tag: 'vulnerability-conversation-tag-filter', task_tag: 'vulnerability-task-tag-filter', + project_id: 'vulnerability-project-filter', severity: 'vulnerability-severity-filter', status: 'vulnerability-status-filter' }; @@ -850,6 +868,12 @@ function renderVulnerabilities(vulnerabilities) { const severityText = vulnSeverityLabel(vuln.severity); const statusText = vulnStatusLabel(vuln.status); const createdDate = new Date(vuln.created_at).toLocaleString(vulnDateLocale()); + const projectLabel = vuln.project_id + ? escapeHtml(typeof getProjectName === 'function' ? getProjectName(vuln.project_id) : vuln.project_id) + : escapeHtml(vulnT('vulnerabilityPage.projectUnbound')); + const projectBadge = vuln.project_id + ? `${escapeHtml(vulnT('vulnerabilityPage.detailProject'))}: ${projectLabel}` + : `${escapeHtml(vulnT('vulnerabilityPage.projectUnbound'))}`; const dlTitle = escapeHtml(vulnT('vulnerabilityPage.downloadMarkdownTitle')); const editTitle = escapeHtml(vulnT('common.edit')); const deleteTitle = escapeHtml(vulnT('common.delete')); @@ -867,6 +891,7 @@ function renderVulnerabilities(vulnerabilities) {
${severityText} ${statusText} + ${projectBadge} ${createdDate}
@@ -895,6 +920,7 @@ function renderVulnerabilities(vulnerabilities) { ${vuln.description ? `
${escapeHtml(vuln.description)}
` : ''}
${vulnDetailField(vulnT('vulnerabilityPage.detailVulnId'), vuln.id, true)} + ${vulnDetailProjectField(vuln)} ${vuln.type ? vulnDetailField(vulnT('vulnerabilityPage.detailType'), vuln.type, false) : ''} ${vuln.target ? vulnDetailField(vulnT('vulnerabilityPage.detailTarget'), vuln.target, false) : ''} ${vulnDetailField(vulnT('vulnerabilityPage.detailConversationId'), vuln.conversation_id, true)} @@ -1005,11 +1031,50 @@ async function changeVulnerabilityPageSize() { await loadVulnerabilities(); } +function buildVulnerabilityProjectOptionsHtml(selectedId) { + const sel = (selectedId || '').trim(); + let html = ``; + const entries = typeof projectNameById !== 'undefined' ? Object.entries(projectNameById) : []; + entries.sort((a, b) => (a[1] || '').localeCompare(b[1] || '', undefined, { sensitivity: 'base' })); + entries.forEach(([id, name]) => { + if (!id) return; + const selected = id === sel ? ' selected' : ''; + html += ``; + }); + if (sel && !entries.some(([id]) => id === sel)) { + html += ``; + } + return html; +} + +async function populateVulnerabilityModalProjectSelect(selectedId) { + const sel = document.getElementById('vulnerability-project-id'); + if (!sel) return; + try { + const res = await apiFetch('/api/projects?limit=200'); + if (res.ok) { + const list = await res.json(); + if (typeof rebuildProjectNameMap === 'function') { + rebuildProjectNameMap(list); + } else if (typeof projectNameById !== 'undefined') { + (list || []).forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; }); + } + } + } catch (e) { + console.warn('加载项目列表失败', e); + } + sel.innerHTML = buildVulnerabilityProjectOptionsHtml(selectedId || ''); + sel.value = selectedId || ''; +} + // 显示添加漏洞模态框 -function showAddVulnerabilityModal() { +async function showAddVulnerabilityModal() { currentVulnerabilityId = null; document.getElementById('vulnerability-modal-title').textContent = vulnT('vulnerability.addVuln'); + const defaultProject = vulnerabilityFilters.project_id || ''; + await populateVulnerabilityModalProjectSelect(defaultProject); + // 清空表单 document.getElementById('vulnerability-conversation-id').value = ''; document.getElementById('vulnerability-conversation-tag').value = ''; @@ -1051,6 +1116,8 @@ async function editVulnerability(id) { document.getElementById('vulnerability-impact').value = vuln.impact || ''; document.getElementById('vulnerability-recommendation').value = vuln.recommendation || ''; + await populateVulnerabilityModalProjectSelect(vuln.project_id || ''); + document.getElementById('vulnerability-modal').style.display = 'block'; } catch (error) { console.error('加载漏洞失败:', error); @@ -1069,8 +1136,11 @@ async function saveVulnerability() { return; } + const projectId = (document.getElementById('vulnerability-project-id')?.value || '').trim(); + const data = { conversation_id: conversationId, + project_id: projectId, conversation_tag: document.getElementById('vulnerability-conversation-tag').value.trim(), task_tag: document.getElementById('vulnerability-task-tag').value.trim(), title: title, @@ -1090,12 +1160,30 @@ async function saveVulnerability() { : '/api/vulnerabilities'; const method = currentVulnerabilityId ? 'PUT' : 'POST'; + let body = data; + if (currentVulnerabilityId) { + body = { + project_id: projectId, + conversation_tag: data.conversation_tag, + task_tag: data.task_tag, + title: data.title, + description: data.description, + severity: data.severity, + status: data.status, + type: data.type, + target: data.target, + proof: data.proof, + impact: data.impact, + recommendation: data.recommendation, + }; + } + const response = await apiFetch(url, { method: method, headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data) + body: JSON.stringify(body) }); if (!response.ok) { @@ -1167,6 +1255,7 @@ function clearVulnerabilityFilters() { 'vulnerability-task-filter', 'vulnerability-conversation-tag-filter', 'vulnerability-task-tag-filter', + 'vulnerability-project-filter', 'vulnerability-severity-filter', 'vulnerability-status-filter' ]; @@ -1178,6 +1267,7 @@ function clearVulnerabilityFilters() { vulnerabilityFilters = { q: '', id: '', + project_id: '', conversation_id: '', task_id: '', conversation_tag: '', @@ -1272,6 +1362,21 @@ function vulnerabilityCopyEncoded(evt, encoded) { } } +function vulnDetailProjectField(vuln) { + const label = vulnT('vulnerabilityPage.detailProject'); + const hint = escapeHtml(vulnT('vulnerabilityPage.projectBindHint')); + return `
+
${escapeHtml(label)}
+
+ +
+
`; +} + function vulnDetailField(label, value, asCode) { if (value === undefined || value === null || String(value) === '') { return ''; @@ -1352,7 +1457,7 @@ function buildVulnerabilityFilterParams() { if (vulnerabilityFilters.q) { params.append('q', vulnerabilityFilters.q); } - const keys = ['id', 'conversation_id', 'task_id', 'conversation_tag', 'task_tag', 'severity', 'status']; + const keys = ['id', 'project_id', 'conversation_id', 'task_id', 'conversation_tag', 'task_tag', 'severity', 'status']; keys.forEach(function (k) { if (vulnerabilityFilters[k]) { params.append(k, vulnerabilityFilters[k]); @@ -1470,3 +1575,80 @@ document.addEventListener('languagechange', function () { } }); +async function bindVulnerabilityProject(vulnId, projectId, silent) { + if (!vulnId) return; + try { + const response = await apiFetch(`/api/vulnerabilities/${encodeURIComponent(vulnId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project_id: projectId || '' }), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || vulnT('vulnerabilityPage.projectBindFailed')); + } + if (!silent) { + alert(vulnT('vulnerabilityPage.projectBindOk')); + } + loadVulnerabilityStats(); + loadVulnerabilities(); + } catch (error) { + console.error('绑定项目失败:', error); + alert(vulnT('vulnerabilityPage.projectBindFailed') + ': ' + error.message); + loadVulnerabilities(); + } +} + +async function refreshVulnerabilityProjectFilter() { + const sel = document.getElementById('vulnerability-project-filter'); + if (!sel) return; + try { + const res = await apiFetch('/api/projects?limit=200'); + if (!res.ok) return; + const list = await res.json(); + if (typeof rebuildProjectNameMap === 'function') { + rebuildProjectNameMap(list); + } else if (typeof projectNameById !== 'undefined') { + list.forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; }); + } + const cur = vulnerabilityFilters.project_id || sel.value || ''; + let html = ''; + (list || []).forEach((p) => { + if (!p.id) return; + const selected = p.id === cur ? ' selected' : ''; + const arch = p.status === 'archived' ? ' [归档]' : ''; + html += ``; + }); + sel.innerHTML = html; + if (cur) sel.value = cur; + const modalSel = document.getElementById('vulnerability-project-id'); + if (modalSel && document.getElementById('vulnerability-modal')?.style.display === 'block') { + const modalCur = modalSel.value || ''; + modalSel.innerHTML = buildVulnerabilityProjectOptionsHtml(modalCur); + modalSel.value = modalCur; + } + } catch (e) { + console.warn('加载项目筛选列表失败', e); + } +} + +function setVulnerabilityProjectFilter(projectId) { + vulnerabilityFilters.project_id = projectId || ''; + const sel = document.getElementById('vulnerability-project-filter'); + if (sel) sel.value = projectId || ''; + applyVulnerabilityFilters(); +} + +function setVulnerabilityIdFilter(vulnId) { + vulnerabilityFilters.id = vulnId || ''; + const el = document.getElementById('vulnerability-exact-id-filter'); + if (el) el.value = vulnId || ''; + applyVulnerabilityFilters(); +} + +window.refreshVulnerabilityProjectFilter = refreshVulnerabilityProjectFilter; +window.setVulnerabilityProjectFilter = setVulnerabilityProjectFilter; +window.setVulnerabilityIdFilter = setVulnerabilityIdFilter; +window.bindVulnerabilityProject = bindVulnerabilityProject; +window.buildVulnerabilityProjectOptionsHtml = buildVulnerabilityProjectOptionsHtml; + diff --git a/web/templates/index.html b/web/templates/index.html index af53fd00..b196fb60 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -161,6 +161,16 @@ 任务管理
+