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 @@
任务管理
+