From 9f092388e13c3ffcfe4117df45d4f507d2421f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:51:35 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 447 +++++++++++++++++---- web/static/i18n/en-US.json | 122 +++++- web/static/i18n/zh-CN.json | 124 +++++- web/static/js/api-docs.js | 5 +- web/static/js/assets.js | 737 +++++++++++++++++++++++++++++++++- web/static/js/chat.js | 22 +- web/static/js/info-collect.js | 4 +- web/static/js/monitor.js | 336 ++++++++++++---- web/static/js/projects.js | 76 +++- web/static/js/rbac-guards.js | 2 + web/static/js/router.js | 42 +- web/static/js/settings.js | 74 +++- web/templates/index.html | 177 ++++++-- 13 files changed, 1913 insertions(+), 255 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index b0b98acc..de0f55e7 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -3957,11 +3957,61 @@ html[data-theme="dark"] .new-chat-btn:focus-visible { } .process-details-content .progress-timeline.expanded { - max-height: 2000px; + max-height: min(70vh, 760px); overflow-x: hidden; overflow-y: auto; opacity: 1; margin-top: 12px; + scroll-behavior: smooth; + /* 详情内部可滚动时优先滚详情;到达边界或运行中取消内层滚动时, + 继续把滚轮自然传给外层 #chat-messages,避免鼠标悬停卡片后“滚不动”。 */ + overscroll-behavior: auto; + scrollbar-gutter: stable; +} + +.process-details-auto-sentinel { + display: block; + width: 100%; + min-height: 34px; + margin: 6px 0 10px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + cursor: default; +} + +.process-details-auto-sentinel.is-error { + color: var(--danger-color, #dc2626); + background: rgba(220, 38, 38, 0.08); + cursor: pointer; +} + +.process-details-jump-latest { + position: sticky; + bottom: 12px; + z-index: 4; + display: block; + width: max-content; + max-width: calc(100% - 24px); + margin: -42px 12px 10px auto; + padding: 7px 12px; + border: 1px solid rgba(59, 130, 246, 0.35); + border-radius: 999px; + background: var(--bg-primary); + color: var(--primary-color, #2563eb); + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.14); + opacity: 0; + pointer-events: none; + transform: translateY(6px); + transition: opacity 0.18s ease, transform 0.18s ease; +} + +.process-details-jump-latest.visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); } .chat-input-container { @@ -5555,6 +5605,7 @@ html[data-theme="dark"] .login-card .login-submit:disabled { max-height: none; overflow-x: hidden; overflow-y: visible; + overscroll-behavior: auto; } .timeline-item { @@ -9142,6 +9193,10 @@ html[data-theme="dark"] .robot-binding-service-hint-icon { flex-shrink: 0; } +.tool-item > .theme-checkbox { + --theme-checkbox-size: 18px; +} + .tool-item-info { flex: 1; min-width: 0; @@ -9177,6 +9232,11 @@ html[data-theme="dark"] .robot-binding-service-hint-icon { margin: 0; } +.tool-resident-toggle .theme-checkbox { + --theme-checkbox-size: 14px; + border-radius: 3px; +} + .external-tool-badge { display: inline-flex; align-items: center; @@ -12932,35 +12992,33 @@ html[data-theme="dark"] .robot-binding-service-hint-icon { display: flex; gap: 6px; min-width: 0; - overflow-x: auto; - overflow-y: hidden; flex: 1 1 auto; - flex-wrap: nowrap; + flex-wrap: wrap; padding: 1px 1px 3px; - scrollbar-width: thin; - scrollbar-color: rgba(148, 163, 184, 0.45) transparent; } .mcp-stats-timeline-moment { display: inline-flex; align-items: center; gap: 6px; - min-width: 156px; - max-width: none; + width: fit-content; + min-width: 0; + max-width: 100%; padding: 4px 8px; border-radius: 999px; background: rgba(59, 130, 246, 0.08); color: #1d4ed8; font-size: 0.6875rem; font-weight: 600; - flex: 1 0 156px; + flex: 0 0 auto; box-sizing: border-box; overflow: hidden; } .mcp-stats-timeline-moment__time { min-width: 0; - flex: 1 1 auto; + max-width: 10rem; + flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -13718,6 +13776,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { } .monitor-execution-checkbox { + --theme-checkbox-size: 18px; cursor: pointer; width: 18px; height: 18px; @@ -13736,6 +13795,7 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { /* 统一表头复选框大小 */ #monitor-select-all { + --theme-checkbox-size: 18px; width: 18px; height: 18px; cursor: pointer; @@ -16839,6 +16899,10 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { cursor: pointer; } +.batch-table-col-checkbox .theme-checkbox { + --theme-checkbox-size: 16px; +} + .batch-footer-actions { display: flex; gap: 12px; @@ -22222,16 +22286,21 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { /* 关键提醒条 */ .dashboard-alert-banner { + --dashboard-alert-accent: #3b82f6; + --dashboard-alert-accent-soft: rgba(59, 130, 246, 0.07); + --dashboard-alert-accent-border: rgba(59, 130, 246, 0.18); + position: relative; + overflow: hidden; display: flex; align-items: center; gap: 14px; - padding: 14px 18px; - border-radius: 14px; + padding: 14px 16px; + border-radius: 12px; margin-bottom: 18px; - background: linear-gradient(135deg, #fff5f5 0%, #fee2e2 100%); - border: 1px solid rgba(239, 68, 68, 0.25); - color: #7f1d1d; - box-shadow: 0 2px 8px rgba(239, 68, 68, 0.08); + background: linear-gradient(105deg, var(--dashboard-alert-accent-soft) 0%, rgba(255, 255, 255, 0.98) 28%, #fff 100%); + border: 1px solid var(--dashboard-alert-accent-border); + color: var(--text-primary, #111827); + box-shadow: 0 5px 18px rgba(15, 23, 42, 0.045), inset 0 1px 0 rgba(255, 255, 255, 0.68); animation: dashboard-alert-slide-in 0.3s ease-out; } @@ -22243,30 +22312,34 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { .dashboard-alert-banner[hidden] { display: none; } .dashboard-alert-banner.is-warning { - background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); - border-color: rgba(245, 158, 11, 0.3); - color: #78350f; + --dashboard-alert-accent: #f59e0b; + --dashboard-alert-accent-soft: rgba(245, 158, 11, 0.07); + --dashboard-alert-accent-border: rgba(245, 158, 11, 0.18); } .dashboard-alert-banner.is-danger { - background: linear-gradient(135deg, #fff5f5 0%, #fee2e2 100%); - border-color: rgba(239, 68, 68, 0.3); - color: #7f1d1d; + --dashboard-alert-accent: #ef4444; + --dashboard-alert-accent-soft: rgba(239, 68, 68, 0.07); + --dashboard-alert-accent-border: rgba(239, 68, 68, 0.18); } .dashboard-alert-icon { width: 36px; height: 36px; border-radius: 10px; - background: rgba(255, 255, 255, 0.7); + background: var(--dashboard-alert-accent-soft); + color: var(--dashboard-alert-accent); + box-shadow: inset 0 0 0 1px var(--dashboard-alert-accent-border); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; } -.dashboard-alert-banner.is-danger .dashboard-alert-icon { color: #dc2626; } -.dashboard-alert-banner.is-warning .dashboard-alert-icon { color: #d97706; } +.dashboard-alert-icon svg { + width: 19px; + height: 19px; +} .dashboard-alert-content { flex: 1; @@ -22274,16 +22347,17 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { } .dashboard-alert-title { - font-size: 0.9375rem; + font-size: 0.9rem; font-weight: 700; - line-height: 1.3; - margin-bottom: 2px; + line-height: 1.35; + margin-bottom: 3px; + letter-spacing: 0.01em; } .dashboard-alert-desc { - font-size: 0.8125rem; + font-size: 0.8rem; line-height: 1.45; - opacity: 0.92; + color: var(--text-secondary, #64748b); } .dashboard-alert-actions { @@ -22294,28 +22368,35 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { .dashboard-alert-btn { appearance: none; - border: 1px solid currentColor; - background: rgba(255, 255, 255, 0.85); - color: inherit; + border: 1px solid var(--dashboard-alert-accent-border); + background: var(--dashboard-alert-accent-soft); + color: var(--dashboard-alert-accent); font-size: 0.8125rem; font-weight: 600; - padding: 6px 14px; - border-radius: 8px; + padding: 6px 12px; + border-radius: 7px; cursor: pointer; - transition: background 0.2s, transform 0.15s; + box-shadow: none; + transition: background 0.16s, border-color 0.16s, color 0.16s, filter 0.16s; white-space: nowrap; } .dashboard-alert-btn:hover { - background: #fff; - transform: translateY(-1px); + filter: saturate(1.15) brightness(0.98); + border-color: var(--dashboard-alert-accent); +} + +.dashboard-alert-btn:focus-visible, +.dashboard-alert-close:focus-visible { + outline: 2px solid var(--dashboard-alert-accent); + outline-offset: 2px; } .dashboard-alert-btn-secondary { background: transparent; - border-color: rgba(0, 0, 0, 0.15); - color: inherit; - opacity: 0.85; + border-color: var(--dashboard-alert-accent-border); + color: var(--dashboard-alert-accent); + box-shadow: none; } /* 告警条「× 忽略」按钮:低权重,hover 时才显形 */ @@ -22630,9 +22711,20 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible { @media (max-width: 720px) { .dashboard-alert-banner { flex-wrap: wrap; + align-items: flex-start; + padding: 14px; } .dashboard-alert-actions { width: 100%; + padding-left: 52px; + } + .dashboard-alert-close { + position: absolute; + top: 10px; + right: 10px; + } + .dashboard-alert-content { + padding-right: 28px; } .dashboard-recommend-list { grid-template-columns: 1fr; @@ -28769,9 +28861,14 @@ html[data-theme="dark"] .skills-management-page .skill-card-actions .btn-seconda } .info-collect-results-table-wrap { + --table-scroll-header-height: 41px; overflow: auto; max-height: 60vh; position: relative; + scrollbar-gutter: stable; + background: var(--bg-primary); + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--text-muted, #94a3b8) 52%, transparent) var(--bg-primary); } .info-collect-table { @@ -28898,6 +28995,10 @@ html[data-theme="dark"] .skills-management-page .skill-card-actions .btn-seconda text-align: center; } +.info-collect-col-select .theme-checkbox { + --theme-checkbox-size: 16px; +} + .info-collect-table thead th.info-collect-col-select, .info-collect-table tbody td.info-collect-col-select { position: sticky; @@ -31406,6 +31507,23 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error { overscroll-behavior: contain; -webkit-overflow-scrolling: touch; } +.projects-panel-pagination { + flex: 0 0 auto; + border-top: 0; + border-radius: 0 0 12px 12px; +} +#project-panel-assets .projects-table-wrap--with-pagination { + border-radius: 12px 12px 0 0; +} +#project-panel-assets .projects-panel-pagination.pagination-fixed { + border-color: var(--border-color, #e2e8f0); + border-top: 0; + box-shadow: none; +} +#project-panel-assets .projects-panel-pagination.pagination-fixed .pagination { + border-top: 1px solid var(--border-color, #e2e8f0); + border-radius: 0 0 12px 12px; +} #project-panel-conversations .projects-table-wrap, #project-panel-assets .projects-table-wrap, #project-panel-vulns .projects-table-wrap { @@ -31434,6 +31552,7 @@ html[data-theme="dark"] .chat-files-toast.chat-toast--error { margin: 0; } .projects-panel-count { flex-shrink: 0; padding: 4px 9px; border-radius: 999px; color: #2563eb; background: #eff6ff; font-size: .75rem; font-weight: 600; } +html[data-theme="dark"] .projects-panel-count { color: var(--accent-color); background: rgba(96, 165, 250, 0.14); } .projects-asset-target { display: block; max-width: 100%; padding: 0; border: 0; background: transparent; color: #2563eb; font: inherit; font-weight: 600; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .projects-asset-target:hover { text-decoration: underline; } #project-panel-assets .data-table--projects th:nth-child(1), #project-panel-assets .data-table--projects td:nth-child(1) { width: 24%; } @@ -33944,17 +34063,51 @@ html[data-theme="dark"] .dashboard-overview-status { border-color: rgba(96, 165, 250, 0.24); } -html[data-theme="dark"] .dashboard-alert-banner, -html[data-theme="dark"] .dashboard-alert-banner.is-warning, -html[data-theme="dark"] .dashboard-alert-banner.is-danger { - background: linear-gradient(135deg, rgba(127, 29, 29, 0.32) 0%, rgba(120, 53, 15, 0.24) 100%); - color: #fecaca; - border-color: rgba(248, 113, 113, 0.35); +html[data-theme="dark"] .dashboard-alert-banner { + --dashboard-alert-accent: #60a5fa; + --dashboard-alert-accent-soft: rgba(96, 165, 250, 0.07); + --dashboard-alert-accent-border: rgba(96, 165, 250, 0.16); + background: linear-gradient(105deg, var(--dashboard-alert-accent-soft) 0%, rgba(17, 27, 45, 0.98) 30%, rgba(15, 24, 40, 0.98) 100%); + color: #f1f5f9; + border-color: var(--dashboard-alert-accent-border); + box-shadow: 0 6px 20px rgba(2, 6, 23, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.025); +} + +html[data-theme="dark"] .dashboard-alert-banner.is-warning { + --dashboard-alert-accent: #fbbf24; + --dashboard-alert-accent-soft: rgba(251, 191, 36, 0.07); + --dashboard-alert-accent-border: rgba(251, 191, 36, 0.17); +} + +html[data-theme="dark"] .dashboard-alert-banner.is-danger { + --dashboard-alert-accent: #f87171; + --dashboard-alert-accent-soft: rgba(248, 113, 113, 0.075); + --dashboard-alert-accent-border: rgba(248, 113, 113, 0.18); +} + +html[data-theme="dark"] .dashboard-alert-desc { + color: #aebbd0; +} + +html[data-theme="dark"] .dashboard-alert-icon { + background: var(--dashboard-alert-accent-soft); } -html[data-theme="dark"] .dashboard-alert-icon, html[data-theme="dark"] .dashboard-alert-btn { - background: rgba(15, 23, 42, 0.72); + color: var(--dashboard-alert-accent); + background: var(--dashboard-alert-accent-soft); + border-color: var(--dashboard-alert-accent-border); + box-shadow: none; +} + +html[data-theme="dark"] .dashboard-alert-btn:hover { + filter: saturate(1.1) brightness(1.18); + border-color: var(--dashboard-alert-accent); +} + +html[data-theme="dark"] .dashboard-alert-btn-secondary { + color: var(--dashboard-alert-accent); + background: transparent; } html[data-theme="dark"] .dashboard-recommend-item.lvl-urgent { @@ -40064,49 +40217,146 @@ html[data-theme="dark"] .workflow-toolbar #workflow-connect-btn[aria-pressed="tr .asset-bar-row strong,.asset-bar-row small { text-align: right; } .asset-bar-row strong { font-size: 11px; } .asset-bar-row small { color: var(--text-secondary, #6b7280); font-size: 11px; } -.asset-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 14px; border: 1px solid var(--border-color, #e5e7eb); border-radius: 12px; background: var(--card-bg, #fff); } -.asset-toolbar input { min-width: 300px; flex: 1; } +.asset-toolbar { display: flex; align-items: stretch; gap: 12px; flex-direction: column; padding: 14px; border: 1px solid var(--border-color, #e5e7eb); border-radius: 12px; background: var(--card-bg, #fff); } +.asset-toolbar-main { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.asset-toolbar-main > input { min-width: 300px; flex: 1; } .asset-toolbar select { min-width: 130px; } .asset-toolbar input,.asset-toolbar select { height: 38px; padding: 0 12px; border: 1px solid var(--border-color, #d8dee8); border-radius: 8px; background: var(--input-bg, #fff); color: var(--text-primary, #111827); } +.asset-advanced-filters { display: grid; grid-template-columns: repeat(5,minmax(140px,1fr)); gap: 12px; padding-top: 14px; border-top: 1px solid var(--border-color,#e5e7eb); } +.asset-advanced-filters[hidden] { display: none; } +.asset-advanced-filters label { display: flex; min-width: 0; flex-direction: column; gap: 6px; color: var(--text-secondary,#64748b); font-size: 11px; font-weight: 600; } +.asset-advanced-filters input,.asset-advanced-filters select { box-sizing: border-box; width: 100%; min-width: 0; font-size: 12px; font-weight: 400; } +.asset-saved-views { display: flex; align-items: center; gap: 9px; padding-top: 12px; border-top: 1px dashed var(--border-color,#e5e7eb); } +.asset-saved-views select { min-width: 220px; } .asset-custom-select { width: 100%; min-width: 0; font-weight: 400; } .asset-custom-select--filter { width: auto; min-width: 150px; flex: 0 0 auto; } -.asset-custom-select .settings-custom-select-trigger { height: 42px; min-height: 42px; border-radius: 8px; background: var(--input-bg, #fff); font-size: 13px; font-weight: 400; } +.asset-advanced-filters .asset-custom-select--filter { width: 100%; min-width: 0; } +.asset-custom-select--saved-view { width: 220px; min-width: 220px; flex: 0 0 220px; } +.asset-custom-select .settings-custom-select-trigger { height: 42px; min-height: 42px; border-color: var(--border-color, #d8dee8); border-radius: 8px; background: var(--input-bg, #fff); color: var(--text-primary, #111827); font-size: 13px; font-weight: 400; } .asset-custom-select .settings-custom-select-caret { width: 16px; height: 16px; color: var(--text-secondary, #64748b); font-size: 0; } .asset-custom-select .settings-custom-select-caret::before { display: block; width: 7px; height: 7px; margin: 2px auto 0; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; content: ''; transform: rotate(45deg); } .asset-custom-select.open .settings-custom-select-caret { transform: rotate(180deg); } -.asset-custom-select .settings-custom-select-menu { min-width: 100%; padding: 5px; border-radius: 9px; background: var(--card-bg, #fff); box-shadow: 0 12px 28px rgba(15,23,42,.16); } -.asset-custom-select .settings-custom-select-option { min-height: 34px; border-radius: 6px; font-size: 13px; } +.asset-custom-select .settings-custom-select-menu { min-width: 100%; padding: 5px; border-color: var(--border-color, #d8dee8); border-radius: 9px; background: var(--input-bg, #fff); color: var(--text-primary, #111827); box-shadow: 0 12px 28px rgba(15,23,42,.16); } +.asset-custom-select .settings-custom-select-option { min-height: 34px; border-radius: 6px; color: var(--text-primary, #111827); font-size: 13px; } +.asset-custom-select .settings-custom-select-option:hover:not(:disabled) { background: var(--bg-secondary, #f4f6f8); } +.asset-custom-select .settings-custom-select-option.is-selected { background: rgba(59,130,246,.12); color: var(--accent-color,#2563eb); } .asset-custom-select--filter .settings-custom-select-trigger { height: 38px; min-height: 38px; padding: 7px 11px; } .asset-custom-select--pagination { width: 76px; flex: 0 0 76px; } .asset-custom-select--pagination .settings-custom-select-trigger { height: 32px; min-height: 32px; padding: 5px 9px; } .asset-custom-select--pagination .settings-custom-select-menu { top: auto; bottom: calc(100% + 6px); } -.asset-batch-actions { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border: 1px solid rgba(37,99,235,.32); border-radius: 10px; background: rgba(37,99,235,.07); } +.asset-batch-actions { position: relative; z-index: 4; display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 12px; border: 1px solid rgba(37,99,235,.32); border-radius: 10px; background: rgba(37,99,235,.07); } .asset-batch-actions[hidden] { display: none; } -.asset-batch-actions > span { margin-right: auto; color: var(--text-primary, #111827); font-size: 13px; font-weight: 650; } +.asset-batch-selection { display: flex; min-width: 0; align-items: center; gap: 9px; color: var(--text-primary, #111827); font-size: 13px; } +.asset-batch-selection-mark { position: relative; width: 20px; height: 20px; flex: 0 0 20px; border-radius: 6px; background: var(--accent-color, #2563eb); } +.asset-batch-selection-mark::after { position: absolute; top: 4px; left: 7px; width: 5px; height: 9px; border: solid #fff; border-width: 0 2px 2px 0; content: ''; transform: rotate(45deg); } +.asset-batch-selection strong { flex: 0 0 auto; font-size: 13px; font-weight: 700; white-space: nowrap; } +.asset-batch-text-action { padding: 2px 0; border: 0; background: transparent; color: var(--text-secondary, #64748b); cursor: pointer; font: inherit; font-size: 12px; white-space: nowrap; } +.asset-batch-text-action:hover { color: var(--accent-color, #2563eb); } +.asset-batch-text-action:focus-visible { border-radius: 4px; outline: 2px solid rgba(37,99,235,.45); outline-offset: 2px; } +.asset-batch-select-all { padding-left: 10px; border-left: 1px solid rgba(37,99,235,.22); color: var(--accent-color, #2563eb); font-weight: 600; } +.asset-batch-all-selected { overflow: hidden; color: var(--accent-color, #2563eb); font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.asset-batch-primary-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; } +.asset-batch-more { position: relative; } +.asset-batch-more > summary { display: inline-flex; min-width: 72px; align-items: center; justify-content: center; gap: 8px; box-sizing: border-box; list-style: none; cursor: pointer; user-select: none; } +.asset-batch-more > summary::-webkit-details-marker { display: none; } +.asset-batch-more > summary::after { width: 6px; height: 6px; margin-top: -3px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; content: ''; transform: rotate(45deg); transition: transform .15s ease; } +.asset-batch-more[open] > summary::after { margin-top: 3px; transform: rotate(225deg); } +.asset-batch-more[open] > summary { border-color: var(--accent-color, #2563eb); color: var(--accent-color, #2563eb); } +.asset-batch-menu { position: absolute; top: calc(100% + 8px); right: 0; width: 210px; padding: 6px; border: 1px solid var(--border-color, #d8dee8); border-radius: 10px; background: var(--card-bg, #fff); box-shadow: 0 16px 36px rgba(15,23,42,.18); } +.asset-batch-menu button { display: flex; box-sizing: border-box; width: 100%; min-height: 36px; align-items: center; padding: 8px 10px; border: 0; border-radius: 7px; background: transparent; color: var(--text-primary, #111827); cursor: pointer; font: inherit; font-size: 13px; text-align: left; } +.asset-batch-menu button:hover:not(:disabled),.asset-batch-menu button:focus-visible { outline: none; background: var(--bg-secondary, #f4f6f8); } +.asset-batch-menu button:disabled { cursor: not-allowed; opacity: .42; } +.asset-batch-menu-label { padding: 5px 10px 3px; color: var(--text-muted, #94a3b8); font-size: 10px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; } +.asset-batch-menu-separator { height: 1px; margin: 5px 4px; background: var(--border-color, #e5e7eb); } +.asset-batch-menu .asset-batch-menu-danger { color: #dc2626; } +.asset-batch-menu .asset-batch-menu-danger:hover:not(:disabled),.asset-batch-menu .asset-batch-menu-danger:focus-visible { background: rgba(220,38,38,.08); } +.asset-batch-permission-action { display: contents; } .asset-list-meta { margin-left: auto; } .asset-list-card { overflow: hidden; border: 1px solid var(--border-color, #e5e7eb); border-radius: 12px; background: var(--card-bg, #fff); box-shadow: 0 1px 3px rgba(15,23,42,.03); } #page-asset-library .asset-list-card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } .asset-table-wrap { overflow: auto; border: 1px solid var(--border-color, #e5e7eb); border-radius: 12px; background: var(--card-bg, #fff); } .asset-list-card .asset-table-wrap { border: 0; border-radius: 0; } -#page-asset-library .asset-list-card .asset-table-wrap { flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; } +#page-asset-library .asset-list-card .asset-table-wrap { --table-scroll-header-height: 41px; flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable; } +#page-asset-library .asset-list-card .asset-table-wrap::-webkit-scrollbar, +.info-collect-results-table-wrap::-webkit-scrollbar { width: 10px; } +#page-asset-library .asset-list-card .asset-table-wrap::-webkit-scrollbar-track { margin-top: var(--table-scroll-header-height); background: transparent; } +.info-collect-results-table-wrap::-webkit-scrollbar-track { + background: linear-gradient( + to bottom, + var(--bg-secondary) 0 var(--table-scroll-header-height), + var(--bg-primary) var(--table-scroll-header-height) 100% + ); +} +#page-asset-library .asset-list-card .asset-table-wrap::-webkit-scrollbar-thumb, +.info-collect-results-table-wrap::-webkit-scrollbar-thumb { + border: 3px solid transparent; + border-radius: 999px; + background: color-mix(in srgb, var(--text-muted, #94a3b8) 62%, transparent); + background-clip: content-box; +} +#page-asset-library .asset-list-card .asset-table-wrap::-webkit-scrollbar-thumb:hover, +.info-collect-results-table-wrap::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--text-secondary, #64748b) 82%, transparent); + background-clip: content-box; +} .asset-list-card #asset-pagination.pagination-fixed { border: 0; border-top: 1px solid var(--border-color, #e5e7eb); border-radius: 0; box-shadow: none; } #page-asset-library .asset-list-card #asset-pagination { flex: 0 0 auto; } .asset-list-card #asset-pagination.pagination-fixed .pagination { border-radius: 0; } .asset-table { width: 100%; min-width: 0; table-layout: fixed; border-collapse: collapse; } .asset-table th,.asset-table td { padding: 13px 14px; border-bottom: 1px solid var(--border-color, #edf0f4); text-align: left; vertical-align: middle; } -.asset-table th { position: sticky; top: 0; background: var(--card-bg, #fff); color: var(--text-secondary, #64748b); font-size: 12px; font-weight: 600; z-index: 1; } +.asset-table th { position: sticky; top: 0; box-sizing: border-box; height: var(--table-scroll-header-height, auto); background: var(--card-bg, #fff); color: var(--text-secondary, #64748b); font-size: 12px; font-weight: 600; z-index: 1; } .asset-table td { font-size: 13px; } .asset-table td:nth-child(2) { width: 220px; max-width: 280px; } .asset-table th:nth-child(2),.asset-table td:nth-child(2) { width: auto; } .asset-table th:nth-child(3),.asset-table td:nth-child(3) { width: 112px; } -.asset-table th:nth-child(4),.asset-table td:nth-child(4) { width: 140px; } -.asset-table th:nth-child(5),.asset-table td:nth-child(5) { width: 180px; } -.asset-table th:nth-child(6),.asset-table td:nth-child(6) { width: 96px; } -.asset-table th:nth-child(7),.asset-table td:nth-child(7) { width: 84px; } +.asset-table th:nth-child(4),.asset-table td:nth-child(4) { width: 126px; } +.asset-table th:nth-child(5),.asset-table td:nth-child(5) { width: 132px; } +.asset-table th:nth-child(6),.asset-table td:nth-child(6) { width: 160px; } +.asset-table th:nth-child(7),.asset-table td:nth-child(7) { width: 96px; } .asset-table th:nth-child(8),.asset-table td:nth-child(8) { width: 84px; } -.asset-table th:nth-child(9),.asset-table td:nth-child(9) { width: 176px; } +.asset-table th:nth-child(9),.asset-table td:nth-child(9) { width: 84px; } +.asset-table th:nth-child(10),.asset-table td:nth-child(10) { width: 166px; } +.asset-ownership { display: flex; min-width: 0; flex-direction: column; gap: 3px; line-height: 1.2; } +.asset-ownership__primary,.asset-ownership__secondary { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.asset-ownership__primary { color: var(--text-primary, #111827); font-weight: 600; } +.asset-ownership__secondary { color: var(--text-secondary, #64748b); font-size: 11px; } +.asset-ownership-empty { color: var(--text-muted, #94a3b8); } .asset-check-cell { width: 42px; min-width: 42px; text-align: center !important; } -.asset-check-cell input { width: 15px; height: 15px; cursor: pointer; accent-color: #2563eb; } +.theme-checkbox { + width: var(--theme-checkbox-size, 16px); + height: var(--theme-checkbox-size, 16px); + margin: 0; + border: 1px solid var(--border-color, #cbd5e1); + border-radius: 4px; + appearance: none; + -webkit-appearance: none; + background-color: var(--bg-primary, #fff); + background-position: center; + background-repeat: no-repeat; + cursor: pointer; + vertical-align: middle; + transition: background-color .15s, border-color .15s, box-shadow .15s; +} +.theme-checkbox:hover { border-color: var(--accent-color, #2563eb); } +.theme-checkbox:focus-visible { + outline: none; + border-color: var(--accent-color, #2563eb); + box-shadow: 0 0 0 3px rgba(37,99,235,.18); +} +.theme-checkbox:checked { + border-color: var(--accent-color, #2563eb); + background-color: var(--accent-color, #2563eb); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='m3.5 8.2 2.8 2.8 6.2-6.2' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} +.theme-checkbox:indeterminate { + border-color: var(--accent-color, #2563eb); + background-color: var(--accent-color, #2563eb); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 8h8' fill='none' stroke='%23fff' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); +} +input.theme-checkbox:disabled { + opacity: .5; + cursor: not-allowed; +} .asset-table td strong,.asset-table td small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .asset-target-link { display: block; max-width: 100%; padding: 0; border: 0; background: transparent; color: #2563eb; font: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .asset-target-link:hover { text-decoration: underline; } @@ -40129,6 +40379,38 @@ html[data-theme="dark"] .workflow-toolbar #workflow-connect-btn[aria-pressed="tr .asset-vulnerability-link { min-width: 28px; padding: 3px 8px; border: 1px solid rgba(239,68,68,.28); border-radius: 999px; background: rgba(239,68,68,.10); color: #dc2626; font-weight: 700; cursor: pointer; } .asset-scan-content { width: min(700px, calc(100vw - 32px)); } .asset-project-content { width: min(500px, calc(100vw - 32px)); } +.asset-bulk-edit-content { width: min(760px, calc(100vw - 32px)); } +.asset-import-content { width: min(920px, calc(100vw - 32px)); max-height: calc(100vh - 48px); margin: 24px auto; } +.asset-import-body { display: flex; flex-direction: column; gap: 14px; } +.asset-import-step { padding: 16px; border: 1px solid var(--border-color, #e5e7eb); border-radius: 12px; background: var(--card-bg, #fff); } +.asset-import-step-heading { display: flex; align-items: center; gap: 11px; margin-bottom: 13px; } +.asset-import-step-heading > span { display: grid; width: 25px; height: 25px; flex: 0 0 25px; place-items: center; border-radius: 50%; background: rgba(37,99,235,.11); color: #2563eb; font-size: 12px; font-weight: 700; } +.asset-import-step-heading > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; } +.asset-import-step-heading strong { color: var(--text-primary, #111827); font-size: 13px; } +.asset-import-step-heading small { color: var(--text-secondary, #64748b); font-size: 12px; font-weight: 400; } +.asset-import-template-actions { display: flex; align-items: center; gap: 12px; padding-left: 36px; } +.asset-import-dropzone { display: flex; box-sizing: border-box; width: 100%; min-height: 118px; align-items: center; justify-content: center; flex-direction: column; gap: 6px; border: 1.5px dashed var(--border-color, #cbd5e1); border-radius: 10px; background: var(--bg-secondary, #f8fafc); color: var(--text-primary, #111827); cursor: pointer; transition: border-color .15s, background .15s, box-shadow .15s; } +.asset-import-dropzone:hover,.asset-import-dropzone.is-dragging { border-color: #2563eb; background: rgba(37,99,235,.055); box-shadow: 0 0 0 3px rgba(37,99,235,.08); } +.asset-import-dropzone:disabled { cursor: wait; opacity: .65; } +.asset-import-dropzone-icon { display: grid; width: 30px; height: 30px; place-items: center; border-radius: 9px; background: rgba(37,99,235,.11); color: #2563eb; font-size: 18px; font-weight: 700; } +.asset-import-dropzone strong { font-size: 13px; } +.asset-import-dropzone small { max-width: 90%; overflow: hidden; color: var(--text-secondary, #64748b); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.asset-import-preview-section { min-height: 0; } +.asset-import-preview-wrap { max-height: 260px; overflow: auto; border: 1px solid var(--border-color, #e5e7eb); border-radius: 9px; } +.asset-import-preview-table { width: 100%; table-layout: fixed; border-collapse: collapse; } +.asset-import-preview-table th,.asset-import-preview-table td { padding: 9px 11px; border-bottom: 1px solid var(--border-color, #edf0f4); text-align: left; font-size: 12px; } +.asset-import-preview-table th { position: sticky; top: 0; z-index: 1; background: var(--card-bg, #fff); color: var(--text-secondary, #64748b); font-weight: 650; } +.asset-import-preview-table th:first-child,.asset-import-preview-table td:first-child { width: 52px; text-align: center; } +.asset-import-preview-table th:nth-child(2),.asset-import-preview-table td:nth-child(2) { width: 30%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.asset-import-preview-table th:nth-child(3),.asset-import-preview-table td:nth-child(3) { width: 18%; } +.asset-import-preview-table th:nth-child(4),.asset-import-preview-table td:nth-child(4) { width: 72px; } +.asset-import-preview-table tr.has-error td { background: rgba(220,38,38,.035); } +.asset-import-result { display: inline-flex; max-width: 100%; align-items: center; gap: 5px; font-size: 12px; overflow-wrap: anywhere; } +.asset-import-result::before { flex: 0 0 auto; font-weight: 800; content: '✓'; } +.asset-import-result--ok { color: #059669; } +.asset-import-result--error { color: #dc2626; } +.asset-import-result--error::before { content: '!'; } +.asset-import-preview-section .form-hint { margin: 9px 0 0; color: var(--text-secondary, #64748b); font-size: 11px; } .asset-project-content .form-hint { margin: 10px 0 0; color: var(--text-secondary, #64748b); font-size: 12px; line-height: 1.5; } .asset-project-content .settings-custom-select-trigger { height: 42px; border-radius: 8px; background: var(--input-bg, var(--bg-primary)); } .asset-project-content .settings-custom-select-menu { z-index: 2700; } @@ -40212,6 +40494,8 @@ html[data-theme="dark"] .asset-list-card, html[data-theme="dark"] .asset-table-wrap, html[data-theme="dark"] .asset-table th, html[data-theme="dark"] .asset-scan-targets, +html[data-theme="dark"] .asset-import-step, +html[data-theme="dark"] .asset-import-preview-table th, html[data-theme="dark"] .asset-detail-item { background: var(--card-bg); border-color: var(--border-color); @@ -40306,11 +40590,35 @@ html[data-theme="dark"] .asset-row-actions .asset-delete { } html[data-theme="dark"] .asset-batch-actions { border-color: rgba(96,165,250,.36); background: rgba(37,99,235,.13); } +html[data-theme="dark"] .asset-batch-selection-mark::after { border-color: #0b1120; } +html[data-theme="dark"] .asset-batch-menu { background: #0f172a; border-color: var(--border-color); box-shadow: 0 16px 36px rgba(0,0,0,.38); } +html[data-theme="dark"] .asset-batch-menu button:hover:not(:disabled),html[data-theme="dark"] .asset-batch-menu button:focus-visible { background: rgba(30,41,59,.9); } +html[data-theme="dark"] .asset-batch-menu .asset-batch-menu-danger:hover:not(:disabled),html[data-theme="dark"] .asset-batch-menu .asset-batch-menu-danger:focus-visible { background: rgba(248,113,113,.1); } +html[data-theme="dark"] .asset-import-dropzone { border-color: var(--border-color); background: rgba(15,23,42,.38); color: var(--text-primary); } +html[data-theme="dark"] .asset-import-dropzone:hover, +html[data-theme="dark"] .asset-import-dropzone.is-dragging { border-color: #60a5fa; background: rgba(37,99,235,.12); } +html[data-theme="dark"] .asset-import-preview-table tr.has-error td { background: rgba(248,113,113,.055); } html[data-theme="dark"] .asset-scan-prompt { background: var(--input-bg); border-color: var(--border-color); color: var(--text-primary); } html[data-theme="dark"] .asset-vulnerability-link { color: #fca5a5; border-color: rgba(248,113,113,.35); background: rgba(248,113,113,.12); } html[data-theme="dark"] .asset-custom-select .settings-custom-select-trigger, -html[data-theme="dark"] .asset-custom-select .settings-custom-select-menu { background: var(--input-bg); border-color: var(--border-color); color: var(--text-primary); } +html[data-theme="dark"] .asset-custom-select .settings-custom-select-menu { background: #0f172a; border-color: var(--border-color); color: var(--text-primary); } html[data-theme="dark"] .asset-custom-select .settings-custom-select-menu { box-shadow: 0 14px 32px rgba(0,0,0,.34); } +html[data-theme="dark"] .asset-custom-select .settings-custom-select-option:hover:not(:disabled) { background: rgba(30,41,59,.78); } +html[data-theme="dark"] .asset-custom-select .settings-custom-select-option.is-selected { background: rgba(59,130,246,.18); color: #93c5fd; } +html[data-theme="dark"] .theme-checkbox { + background-color: var(--bg-primary); +} +html[data-theme="dark"] .theme-checkbox:focus-visible { + box-shadow: 0 0 0 3px rgba(96,165,250,.22); +} +html[data-theme="dark"] .theme-checkbox:checked { + background-color: var(--accent-color); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='m3.5 8.2 2.8 2.8 6.2-6.2' fill='none' stroke='%230b1120' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} +html[data-theme="dark"] .theme-checkbox:indeterminate { + background-color: var(--accent-color); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 8h8' fill='none' stroke='%230b1120' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"); +} html[data-theme="dark"] .asset-detail-grid { border-color: var(--border-color); @@ -40342,7 +40650,11 @@ html[data-theme="dark"] .asset-form-error { color: #fca5a5; } @media (max-width: 820px) { .asset-table th:nth-child(3),.asset-table td:nth-child(3) { display: none; } .asset-table th:nth-child(5),.asset-table td:nth-child(5) { width: 145px; } - .asset-table th:nth-child(9),.asset-table td:nth-child(9) { width: 150px; } + .asset-table th:nth-child(10),.asset-table td:nth-child(10) { width: 150px; } + .asset-batch-actions { align-items: stretch; flex-direction: column; } + .asset-batch-selection { min-height: 30px; } + .asset-batch-primary-actions { width: 100%; } + .asset-batch-primary-actions > button { flex: 1 1 auto; } } @media (max-width: 760px) { .asset-overview-summary { grid-template-columns: 1fr; } @@ -40351,4 +40663,5 @@ html[data-theme="dark"] .asset-form-error { color: #fca5a5; } .asset-coverage-layout { grid-template-columns: 1fr; } .asset-recent-card { min-height: 230px; } } -@media (max-width: 640px) { .asset-editor-grid,.asset-detail-grid { grid-template-columns: 1fr; } .asset-total-card,.asset-recent-card,.asset-panel { padding: 18px; } .asset-type-grid,.asset-coverage-metrics { grid-template-columns: 1fr; } .asset-type-item + .asset-type-item,.asset-coverage-metrics > div + div,.asset-coverage-metrics > div:nth-child(4) { border-top: 1px solid var(--border-color,#e5e7eb); border-left: 0; } .asset-trend-header { grid-template-columns: 1fr auto; grid-template-areas: "heading summary"; column-gap: 12px; } .asset-chart-legend { display: none; } .asset-chart-summary { justify-content: flex-end; padding: 0; } .asset-chart-summary span { display: none; } .asset-protocol-summary { box-sizing: border-box; width: 100%; } .asset-protocol-summary__main { align-items: flex-start; flex-direction: column; } .asset-protocol-top-list { grid-template-columns: 1fr; } .asset-bar-row,.asset-protocol-columns { grid-template-columns: 20px 64px 1fr 36px; gap: 8px; padding-right: 4px; padding-left: 4px; } .asset-bar-row small,.asset-protocol-columns span:last-child { display: none; } .asset-editor-content { width: 100%; max-height: 100vh; min-height: 100vh; margin: 0; border-radius: 0; } .asset-editor-content .modal-header,.asset-editor-body,.asset-editor-content .modal-footer { padding-left: 16px; padding-right: 16px; } .asset-editor-subtitle { max-width: 270px; } .asset-editor-primary { padding: 14px; } .asset-editor-group-heading { align-items: flex-start; flex-wrap: wrap; gap: 3px 8px; } .asset-editor-group-heading p { width: 100%; } .asset-editor-section-group + .asset-editor-section-group { margin-top: 20px; padding-top: 20px; } .asset-editor-wide,.asset-detail-item--wide { grid-column: auto; } .asset-toolbar input { min-width: 100%; } } +@media (max-width: 1100px) { .asset-advanced-filters { grid-template-columns: repeat(3,minmax(140px,1fr)); } } +@media (max-width: 640px) { .asset-editor-grid,.asset-detail-grid { grid-template-columns: 1fr; } .asset-total-card,.asset-recent-card,.asset-panel { padding: 18px; } .asset-type-grid,.asset-coverage-metrics { grid-template-columns: 1fr; } .asset-type-item + .asset-type-item,.asset-coverage-metrics > div + div,.asset-coverage-metrics > div:nth-child(4) { border-top: 1px solid var(--border-color,#e5e7eb); border-left: 0; } .asset-trend-header { grid-template-columns: 1fr auto; grid-template-areas: "heading summary"; column-gap: 12px; } .asset-chart-legend { display: none; } .asset-chart-summary { justify-content: flex-end; padding: 0; } .asset-chart-summary span { display: none; } .asset-protocol-summary { box-sizing: border-box; width: 100%; } .asset-protocol-summary__main { align-items: flex-start; flex-direction: column; } .asset-protocol-top-list { grid-template-columns: 1fr; } .asset-bar-row,.asset-protocol-columns { grid-template-columns: 20px 64px 1fr 36px; gap: 8px; padding-right: 4px; padding-left: 4px; } .asset-bar-row small,.asset-protocol-columns span:last-child { display: none; } .asset-editor-content,.asset-import-content,.asset-bulk-edit-content { width: 100%; max-height: 100vh; min-height: 100vh; margin: 0; border-radius: 0; } .asset-editor-content .modal-header,.asset-editor-body,.asset-editor-content .modal-footer { padding-left: 16px; padding-right: 16px; } .asset-editor-subtitle { max-width: 270px; } .asset-editor-primary { padding: 14px; } .asset-editor-group-heading { align-items: flex-start; flex-wrap: wrap; gap: 3px 8px; } .asset-editor-group-heading p { width: 100%; } .asset-editor-section-group + .asset-editor-section-group { margin-top: 20px; padding-top: 20px; } .asset-editor-wide,.asset-detail-item--wide { grid-column: auto; } .asset-toolbar-main > input { min-width: 100%; } .asset-advanced-filters { grid-template-columns: 1fr 1fr; } .asset-saved-views { align-items: stretch; flex-direction: column; } .asset-saved-views select { width: 100%; min-width: 0; } .asset-custom-select--saved-view { width: 100%; min-width: 0; flex-basis: auto; } .asset-import-template-actions { align-items: flex-start; flex-direction: column; padding-left: 0; } .asset-import-preview-table th:nth-child(3),.asset-import-preview-table td:nth-child(3),.asset-import-preview-table th:nth-child(4),.asset-import-preview-table td:nth-child(4) { display: none; } .asset-batch-selection { flex-wrap: wrap; } .asset-batch-primary-actions { display: grid; grid-template-columns: 1fr 1fr; } .asset-batch-primary-actions > .btn-primary { grid-column: 1 / -1; grid-row: 1; } .asset-batch-more { width: 100%; } .asset-batch-more > summary { width: 100%; } .asset-batch-menu { right: auto; left: 0; width: min(260px,calc(100vw - 48px)); } } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index af1e63dd..66844eb6 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -585,6 +585,9 @@ "penetrationTestDetail": "Task execution details", "expandDetail": "Expand details", "expandDetailLazyHint": "Expand details (loads iteration details on click)", + "loadingEarlierDetails": "Loading earlier entries…", + "loadingLaterDetails": "Loading newer entries…", + "backToLatestProgress": "↓ Back to latest", "viewToolDetail": "View details", "collapseToolDetail": "Collapse", "liveTimelinePruned": "Collapsed the first {{count}} live process details. View the full record page by page after the task completes.", @@ -1063,6 +1066,47 @@ "coverageOfTotal": "{{percent}}% of all assets", "coverageMeta": "{{scanned}} / {{total}} covered", "addAsset": "+ Add asset", + "bulkImport": "Bulk import", + "bulkImportTitle": "Bulk import assets", + "bulkImportSubtitle": "Fill in a template, upload it, then review validation results before importing", + "downloadTemplate": "Download template", + "downloadTemplateHint": "XLSX is recommended; CSV is convenient for exports from other systems", + "downloadXlsx": "Download XLSX template", + "downloadCsv": "Download CSV template", + "uploadFile": "Upload file", + "uploadFileHint": "Supports .xlsx and .csv, up to 100,000 rows and 100 MB", + "chooseOrDropFile": "Choose a file or drop it here", + "fileNotSelected": "No file selected", + "dataPreview": "Data preview", + "rowNumber": "Row", + "validationResult": "Validation", + "validationPassed": "Valid", + "importValidRows": "Import valid rows", + "importValidRowsCount": "Import {{count}} valid rows", + "importPreviewSummary": "{{total}} rows: {{valid}} valid, {{invalid}} need attention", + "previewLimited": "Showing the first 100 rows; all {{count}} rows will be processed", + "fileTypeInvalid": "Only .xlsx and .csv files are supported", + "fileTooLarge": "The file must not exceed 100 MB", + "spreadsheetUnavailable": "The spreadsheet component failed to load; refresh and try again", + "fileParseFailed": "Could not parse the file; make sure it is valid and not corrupted", + "fileHasNoData": "The file has no rows to import", + "headerInvalid": "No template columns were recognized; use the downloaded template", + "tooManyRows": "Data must not exceed {{count}} rows", + "importTargetRequired": "Target address is missing", + "importStatusInvalid": "Status must be active or inactive", + "importProjectNotFound": "Project does not exist or is not accessible: {{project}}", + "importTagsInvalid": "Use at most 30 tags, with at most 64 characters per tag", + "importFieldTooLong": "Field {{field}} is too long", + "duplicateFileRow": "Duplicates row {{row}}", + "bulkImportDone": "Import complete: {{created}} created, {{updated}} updated, {{skipped}} skipped", + "templateGuideTitle": "Asset bulk import instructions", + "templateGuideRequired": "Required: target, or at least one of host / ip / domain", + "templateGuideTarget": "target examples: https://example.com:443, example.com, 1.1.1.1:22", + "templateGuideProject": "project accepts an existing project name or ID; leave blank for no project", + "templateGuideTags": "Separate tags with commas; at most 30 tags", + "templateGuideStatus": "status accepts active / inactive and defaults to active", + "templateGuideLimit": "Enter data in the Assets sheet; up to 100,000 rows", + "instructionsSheet": "Instructions", "addAssetTitle": "Add asset", "editAssetTitle": "Edit asset", "editorSubtitle": "Enter one target; other details are parsed automatically or can be added later", @@ -1138,6 +1182,8 @@ "selectAsset": "Select asset", "selectedCount": "{{count}} selected", "clearSelection": "Clear selection", + "selectAllResults": "Select all {{count}}", + "allResultsSelected": "All {{count}} matching assets selected", "bindProject": "Bind project", "bindProjectTitle": "Bind project", "chooseProject": "Choose a project", @@ -1173,7 +1219,62 @@ "riskLow": "Low", "riskInfo": "Info", "riskNormal": "Clear", - "riskUnassessed": "Unassessed" + "riskUnassessed": "Unassessed", + "advancedFilters": "Advanced filters", + "resetFilters": "Reset", + "allRisks": "All risk levels", + "minVulnerabilityCount": "Minimum findings", + "scanState": "Scan status", + "allScanStates": "All", + "neverScannedFilter": "Never scanned", + "overdue30": "Not scanned for 30 days", + "overdue60": "Not scanned for 60 days", + "overdue90": "Not scanned for 90 days", + "scannedFilter": "Scanned", + "responsiblePerson": "Owner", + "department": "Department", + "ownership": "Ownership", + "unassigned": "Unassigned", + "businessSystem": "Business system", + "environment": "Environment", + "criticality": "Criticality", + "allEnvironments": "All environments", + "environmentProduction": "Production", + "environmentStaging": "Staging", + "environmentTesting": "Testing", + "environmentDevelopment": "Development", + "environmentOther": "Other", + "allCriticalities": "All criticalities", + "criticalityCritical": "Mission critical", + "criticalityHigh": "High", + "criticalityMedium": "Medium", + "criticalityLow": "Low", + "firstSeenStart": "First seen from", + "firstSeenEnd": "First seen to", + "lastSeenStart": "Last seen from", + "lastSeenEnd": "Last seen to", + "sort": "Sort", + "sortLastSeenDesc": "Last seen (newest)", + "sortLastScanAsc": "Most overdue scan", + "sortLastScanDesc": "Most recently scanned", + "sortRiskDesc": "Risk (high to low)", + "sortVulnerabilityDesc": "Findings (most first)", + "sortFirstSeenDesc": "First seen (newest)", + "sortHostAsc": "Target name", + "sortPortAsc": "Port", + "savedViews": "Saved filter views", + "saveCurrentView": "Save current view", + "deleteView": "Delete view", + "bulkEdit": "Bulk edit", + "moreActions": "More", + "export": "Export", + "exportCsv": "Export CSV", + "exportXlsx": "Export XLSX", + "mergeDuplicates": "Merge duplicates", + "mergeRequiresMultiple": "Select at least two duplicate assets", + "batchDelete": "Bulk delete…", + "responsibilityBusiness": "Ownership and business context", + "responsibilityBusinessHint": "Use ownership and business priority to drive operations and risk ranking" }, "vulnerability": { "title": "Vulnerability Management", @@ -1431,10 +1532,10 @@ "security": "Security", "rbac": "Platform permissions", "audit": "Audit logs", - "infocollect": "Recon" + "infocollect": "Asset management" }, "infocollect": { - "title": "Reconnaissance" + "title": "Asset management" }, "hitl": { "title": "Human-in-the-loop", @@ -1772,7 +1873,8 @@ "chatUploads": "Chat Uploads", "robotIntegration": "Robot Integration", "markdownAgents": "Markdown Agents", - "projectManagement": "Project Management" + "projectManagement": "Project Management", + "assetManagement": "Asset Management" }, "summary": { "login": "User login", @@ -1930,7 +2032,8 @@ "listProjectFactEdges": "List all project fact edges", "createProjectFactEdge": "Add fact edge", "deleteProjectFactEdge": "Delete fact edge", - "promoteAttackChainToProject": "Promote conversation attack chain to project fact graph" + "promoteAttackChainToProject": "Promote conversation attack chain to project fact graph", + "importAssets": "Import assets in bulk" }, "response": { "getSuccess": "Success", @@ -1995,7 +2098,11 @@ "factGraphNodesEdges": "nodes + edges", "edgeList": "Edge list", "edgeCreated": "Edge created", - "promoteAttackChainResult": "Promotion result (facts/edges/graph)" + "promoteAttackChainResult": "Promotion result (facts/edges/graph)", + "assetImportCompleted": "Import completed", + "assetImportValidationFailed": "Asset count or field validation failed", + "assetImportForbidden": "Missing asset:write permission or access to the specified project", + "assetImportTransactionFailed": "Import transaction failed" } }, "chatGroup": { @@ -2445,6 +2552,7 @@ "providerClaude": "Claude (Anthropic Messages API)", "visionProviderReuseOpenAI": "Reuse OpenAI config (leave empty)", "fofaConfig": "FOFA config", + "fofaConfigHint": "Used for asset discovery and import; only an API key is required.", "agentConfig": "Agent config", "knowledgeConfig": "Knowledge base config", "baseUrl": "Base URL", @@ -2469,8 +2577,6 @@ "openaiReasoningAllowClient": "Allow chat page to override reasoning options", "fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all (optional)", "fofaBaseUrlHint": "Leave empty for default.", - "email": "Email", - "fofaEmailPlaceholder": "Enter FOFA email", "fofaApiKeyPlaceholder": "Enter FOFA API Key", "fofaApiKeyHint": "Stored in server config (config.yaml) only.", "maxIterations": "Max iterations", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 0fd17866..9a82b07a 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -573,6 +573,9 @@ "penetrationTestDetail": "任务执行详情", "expandDetail": "展开详情", "expandDetailLazyHint": "展开详情(点击后加载迭代详情)", + "loadingEarlierDetails": "正在加载更早记录…", + "loadingLaterDetails": "正在加载更新记录…", + "backToLatestProgress": "↓ 回到最新进度", "viewToolDetail": "查看详情", "collapseToolDetail": "收起", "liveTimelinePruned": "已收起前 {{count}} 条实时过程详情,任务完成后可按页查看完整记录", @@ -1051,6 +1054,47 @@ "coverageOfTotal": "占全部资产 {{percent}}%", "coverageMeta": "{{scanned}} / {{total}} 已覆盖", "addAsset": "+ 新增资产", + "bulkImport": "批量导入", + "bulkImportTitle": "批量导入资产", + "bulkImportSubtitle": "下载模板填写后上传,提交前会先校验并预览数据", + "downloadTemplate": "下载模板", + "downloadTemplateHint": "推荐 XLSX;CSV 适合从其他系统快速导出", + "downloadXlsx": "下载 XLSX 模板", + "downloadCsv": "下载 CSV 模板", + "uploadFile": "上传文件", + "uploadFileHint": "支持 .xlsx 和 .csv,最多 100000 行、100 MB", + "chooseOrDropFile": "选择文件,或拖拽到此处", + "fileNotSelected": "尚未选择文件", + "dataPreview": "数据预览", + "rowNumber": "行号", + "validationResult": "校验结果", + "validationPassed": "通过", + "importValidRows": "导入有效数据", + "importValidRowsCount": "导入 {{count}} 条有效数据", + "importPreviewSummary": "共 {{total}} 行,{{valid}} 行有效,{{invalid}} 行需修正", + "previewLimited": "仅展示前 100 行;提交时将处理全部 {{count}} 行", + "fileTypeInvalid": "仅支持 .xlsx 和 .csv 文件", + "fileTooLarge": "文件不能超过 100 MB", + "spreadsheetUnavailable": "表格组件加载失败,请刷新后重试", + "fileParseFailed": "无法解析文件,请确认文件未损坏且格式正确", + "fileHasNoData": "文件中没有可导入的数据", + "headerInvalid": "未识别到模板字段,请使用下载的模板填写", + "tooManyRows": "数据不能超过 {{count}} 行", + "importTargetRequired": "缺少目标地址", + "importStatusInvalid": "状态仅支持 active 或 inactive", + "importProjectNotFound": "项目不存在或无权访问:{{project}}", + "importTagsInvalid": "标签最多 30 个,单个标签最多 64 个字符", + "importFieldTooLong": "字段 {{field}} 内容过长", + "duplicateFileRow": "与第 {{row}} 行重复", + "bulkImportDone": "导入完成:新增 {{created}} 条,更新 {{updated}} 条,跳过 {{skipped}} 条", + "templateGuideTitle": "资产批量导入填写说明", + "templateGuideRequired": "必填:target,或 host / ip / domain 中至少一项", + "templateGuideTarget": "target 示例:https://example.com:443、example.com、1.1.1.1:22", + "templateGuideProject": "project 填写系统中已有的项目名称或项目 ID,留空表示不绑定", + "templateGuideTags": "tags 使用逗号分隔,最多 30 个", + "templateGuideStatus": "status 仅支持 active / inactive,留空默认为 active", + "templateGuideLimit": "请在“Assets”工作表中填写,最多 100000 行", + "instructionsSheet": "填写说明", "addAssetTitle": "新增资产", "editAssetTitle": "编辑资产", "editorSubtitle": "输入一个目标即可,其余信息可自动解析或稍后补充", @@ -1125,7 +1169,9 @@ "selectPage": "选择本页", "selectAsset": "选择资产", "selectedCount": "已选择 {{count}} 项", - "clearSelection": "取消选择", + "clearSelection": "清除选择", + "selectAllResults": "选择全部 {{count}} 项", + "allResultsSelected": "已选择当前筛选结果中的 {{count}} 项", "bindProject": "绑定项目", "bindProjectTitle": "绑定项目", "chooseProject": "请选择项目", @@ -1161,7 +1207,62 @@ "riskLow": "低危", "riskInfo": "提示", "riskNormal": "正常", - "riskUnassessed": "未评估" + "riskUnassessed": "未评估", + "advancedFilters": "高级筛选", + "resetFilters": "重置", + "allRisks": "全部风险", + "minVulnerabilityCount": "漏洞数量至少", + "scanState": "扫描状态", + "allScanStates": "全部", + "neverScannedFilter": "从未扫描", + "overdue30": "30 天未扫描", + "overdue60": "60 天未扫描", + "overdue90": "90 天未扫描", + "scannedFilter": "已扫描", + "responsiblePerson": "负责人", + "department": "部门", + "ownership": "归属", + "unassigned": "未分配", + "businessSystem": "业务系统", + "environment": "环境", + "criticality": "重要性", + "allEnvironments": "全部环境", + "environmentProduction": "生产", + "environmentStaging": "预发布", + "environmentTesting": "测试", + "environmentDevelopment": "开发", + "environmentOther": "其他", + "allCriticalities": "全部级别", + "criticalityCritical": "核心", + "criticalityHigh": "重要", + "criticalityMedium": "一般", + "criticalityLow": "低", + "firstSeenStart": "首次发现开始", + "firstSeenEnd": "首次发现结束", + "lastSeenStart": "最近发现开始", + "lastSeenEnd": "最近发现结束", + "sort": "排序", + "sortLastSeenDesc": "最近发现(新到旧)", + "sortLastScanAsc": "最久未扫描优先", + "sortLastScanDesc": "最近扫描优先", + "sortRiskDesc": "风险从高到低", + "sortVulnerabilityDesc": "漏洞数量从多到少", + "sortFirstSeenDesc": "首次发现(新到旧)", + "sortHostAsc": "目标名称", + "sortPortAsc": "端口", + "savedViews": "保存的筛选视图", + "saveCurrentView": "保存当前视图", + "deleteView": "删除视图", + "bulkEdit": "批量编辑", + "moreActions": "更多", + "export": "导出", + "exportCsv": "导出 CSV", + "exportXlsx": "导出 XLSX", + "mergeDuplicates": "合并重复资产", + "mergeRequiresMultiple": "请至少选择两个重复资产", + "batchDelete": "批量删除…", + "responsibilityBusiness": "责任与业务属性", + "responsibilityBusinessHint": "用于资产归属、运营分级与风险排序" }, "vulnerability": { "title": "漏洞管理", @@ -1419,10 +1520,10 @@ "security": "安全设置", "rbac": "平台权限", "audit": "日志审计", - "infocollect": "信息收集" + "infocollect": "资产管理" }, "infocollect": { - "title": "信息收集" + "title": "资产管理" }, "hitl": { "title": "人机协同", @@ -1760,7 +1861,8 @@ "chatUploads": "对话附件", "robotIntegration": "机器人集成", "markdownAgents": "多代理Markdown", - "projectManagement": "项目管理" + "projectManagement": "项目管理", + "assetManagement": "资产管理" }, "summary": { "login": "用户登录", @@ -1918,7 +2020,8 @@ "listProjectFactEdges": "列出项目全部事实边", "createProjectFactEdge": "添加事实边", "deleteProjectFactEdge": "删除事实边", - "promoteAttackChainToProject": "将对话攻击链沉淀到项目事实图" + "promoteAttackChainToProject": "将对话攻击链沉淀到项目事实图", + "importAssets": "批量导入资产" }, "response": { "getSuccess": "获取成功", @@ -1983,7 +2086,11 @@ "factGraphNodesEdges": "nodes + edges", "edgeList": "边列表", "edgeCreated": "边已创建", - "promoteAttackChainResult": "沉淀结果(facts/edges/graph)" + "promoteAttackChainResult": "沉淀结果(facts/edges/graph)", + "assetImportCompleted": "导入完成", + "assetImportValidationFailed": "数量或资产字段校验失败", + "assetImportForbidden": "缺少 asset:write 权限或无权访问指定项目", + "assetImportTransactionFailed": "导入事务失败" } }, "chatGroup": { @@ -2433,6 +2540,7 @@ "providerClaude": "Claude (Anthropic Messages API)", "visionProviderReuseOpenAI": "OpenAI 配置(留空复用)", "fofaConfig": "FOFA 配置", + "fofaConfigHint": "用于资产发现与导入,仅需配置 API Key。", "agentConfig": "Agent 配置", "knowledgeConfig": "知识库配置", "baseUrl": "Base URL", @@ -2457,8 +2565,6 @@ "openaiReasoningAllowClient": "允许对话页覆盖推理选项", "fofaBaseUrlPlaceholder": "https://fofa.info/api/v1/search/all(可选)", "fofaBaseUrlHint": "留空则使用默认地址。", - "email": "Email", - "fofaEmailPlaceholder": "输入 FOFA 账号邮箱", "fofaApiKeyPlaceholder": "输入 FOFA API Key", "fofaApiKeyHint": "仅保存在服务器配置中(`config.yaml`)。", "maxIterations": "最大迭代次数", diff --git a/web/static/js/api-docs.js b/web/static/js/api-docs.js index f8e281e8..bd80b8e0 100644 --- a/web/static/js/api-docs.js +++ b/web/static/js/api-docs.js @@ -41,7 +41,10 @@ function buildApiSpecTagToKey() { function translateApiDocTag(tag) { if (!tag) return tag; var key = apiSpecTagToKey[tag]; - return key ? _t('apiDocs.tags.' + key) : tag; + if (!key) return tag; + var i18nKey = 'apiDocs.tags.' + key; + var translated = _t(i18nKey); + return translated === i18nKey ? tag : translated; } function translateApiDocSummaryFromOp(op) { var key = op && op['x-i18n-summary']; diff --git a/web/static/js/assets.js b/web/static/js/assets.js index f72aa507..37150705 100644 --- a/web/static/js/assets.js +++ b/web/static/js/assets.js @@ -1,4 +1,5 @@ const ASSET_PAGE_SIZE_KEY = 'cyberstrike.asset_page_size'; +const ASSET_SAVED_VIEWS_KEY = 'cyberstrike.asset_saved_views'; function getAssetPageSize() { try { const value = Number(localStorage.getItem(ASSET_PAGE_SIZE_KEY)); @@ -7,15 +8,26 @@ function getAssetPageSize() { return 20; } } -const assetPageState = { page: 1, pageSize: getAssetPageSize(), total: 0, totalPages: 1, items: [], projects: [], projectsLoaded: false, detailIndex: -1, editIndex: -1, detailAsset: null, editAsset: null, selected: new Map(), scanMode: 'chat', scanAssets: [], editorTags: [], editorDirty: false, editorBusy: false, editorReturnFocus: null, editorInteractionsReady: false, editorParsedTarget: '' }; +const assetPageState = { page: 1, pageSize: getAssetPageSize(), total: 0, totalPages: 1, items: [], projects: [], projectsLoaded: false, detailIndex: -1, editIndex: -1, detailAsset: null, editAsset: null, selected: new Map(), selectionQuery: '', allMatchingSelected: false, scanMode: 'chat', scanAssets: [], editorTags: [], editorDirty: false, editorBusy: false, editorReturnFocus: null, editorInteractionsReady: false, editorParsedTarget: '', importRows: [], importFileName: '', importBusy: false, importInteractionsReady: false, importReturnFocus: null }; let assetOverviewDays = 30; const ASSET_CUSTOM_SELECT_IDS = [ 'asset-status-filter', 'asset-project-filter', + 'asset-risk-filter', + 'asset-scan-filter', + 'asset-environment-filter', + 'asset-criticality-filter', + 'asset-sort-filter', + 'asset-saved-view-select', 'asset-batch-project', 'asset-edit-project', 'asset-edit-status', + 'asset-edit-environment', + 'asset-edit-criticality', + 'asset-bulk-status', + 'asset-bulk-environment', + 'asset-bulk-criticality', 'asset-page-size-pagination' ]; @@ -25,7 +37,8 @@ function enhanceAssetSelect(select) { const wrapper = select.closest('.settings-custom-select'); if (!wrapper) return; wrapper.classList.add('asset-custom-select'); - wrapper.classList.toggle('asset-custom-select--filter', select.id === 'asset-status-filter' || select.id === 'asset-project-filter'); + wrapper.classList.toggle('asset-custom-select--filter', Boolean(select.closest('.asset-toolbar'))); + wrapper.classList.toggle('asset-custom-select--saved-view', select.id === 'asset-saved-view-select'); wrapper.classList.toggle('asset-custom-select--pagination', select.id === 'asset-page-size-pagination'); } @@ -52,6 +65,319 @@ function assetT(key, fallback, options) { return fallback; } +const ASSET_IMPORT_MAX_ROWS = 100000; +const ASSET_IMPORT_MAX_BYTES = 100 * 1024 * 1024; +const ASSET_IMPORT_COLUMNS = ['target', 'project', 'tags', 'host', 'ip', 'domain', 'port', 'protocol', 'title', 'server', 'country', 'province', 'city', 'responsible_person', 'department', 'business_system', 'environment', 'criticality', 'status']; +const ASSET_IMPORT_HEADER_ALIASES = { + target: ['target', 'asset', 'assetaddress', '目标', '资产', '资产地址'], + project: ['project', 'projectname', 'projectid', '项目', '项目名称', '项目id', '所属项目'], + tags: ['tags', 'tag', '标签'], + host: ['host', 'url', '完整url', '主机'], + ip: ['ip', 'ipaddress', 'ip地址'], + domain: ['domain', '域名'], + port: ['port', '端口'], + protocol: ['protocol', 'scheme', '协议'], + title: ['title', 'pagetitle', '标题', '页面标题'], + server: ['server', 'service', 'product', '服务', '产品', '服务指纹'], + country: ['country', 'countryregion', '国家', '国家地区'], + province: ['province', 'state', '省份', '州'], + city: ['city', '城市'], + responsible_person: ['responsibleperson', 'owner', '负责人', '责任人'], + department: ['department', '部门'], + business_system: ['businesssystem', 'system', '业务系统', '系统'], + environment: ['environment', 'env', '环境'], + criticality: ['criticality', 'importance', '重要性', '重要等级'], + status: ['status', '状态'] +}; + +function normalizeAssetImportHeader(value) { + return String(value == null ? '' : value).replace(/^\uFEFF/, '').trim().toLowerCase().replace(/[\s_\-/.()()]+/g, ''); +} + +function assetImportColumnForHeader(value) { + const normalized = normalizeAssetImportHeader(value); + return ASSET_IMPORT_COLUMNS.find(column => ASSET_IMPORT_HEADER_ALIASES[column].includes(normalized)) || ''; +} + +function assetImportTemplateHeaders() { + return ASSET_IMPORT_COLUMNS.slice(); +} + +function downloadAssetTemplate(format) { + const headers = assetImportTemplateHeaders(); + if (format === 'csv') { + const csv = '\uFEFF' + headers.join(',') + '\r\n'; + const link = document.createElement('a'); + link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); + link.download = 'asset-import-template.csv'; + link.click(); + setTimeout(() => URL.revokeObjectURL(link.href), 0); + return; + } + if (!window.XLSX) { + setAssetImportError(assetT('assets.spreadsheetUnavailable', '表格组件加载失败,请刷新后重试')); + return; + } + const workbook = XLSX.utils.book_new(); + const assetSheet = XLSX.utils.aoa_to_sheet([headers]); + assetSheet['!cols'] = headers.map(name => ({ wch: ['target', 'host'].includes(name) ? 28 : 16 })); + assetSheet['!autofilter'] = { ref: `A1:${XLSX.utils.encode_col(headers.length - 1)}1` }; + const instructions = [ + [assetT('assets.templateGuideTitle', '资产批量导入填写说明')], + [assetT('assets.templateGuideRequired', '必填:target,或 host / ip / domain 中至少一项')], + [assetT('assets.templateGuideTarget', 'target 示例:https://example.com:443、example.com、1.1.1.1:22')], + [assetT('assets.templateGuideProject', 'project 填写系统中已有的项目名称或项目 ID,留空表示不绑定')], + [assetT('assets.templateGuideTags', 'tags 使用逗号分隔,最多 30 个')], + [assetT('assets.templateGuideStatus', 'status 仅支持 active / inactive,留空默认为 active')], + [assetT('assets.templateGuideLimit', '请在“Assets”工作表中填写,最多 100000 行')] + ]; + const guideSheet = XLSX.utils.aoa_to_sheet(instructions); + guideSheet['!cols'] = [{ wch: 86 }]; + XLSX.utils.book_append_sheet(workbook, assetSheet, 'Assets'); + XLSX.utils.book_append_sheet(workbook, guideSheet, assetT('assets.instructionsSheet', '填写说明').slice(0, 31)); + XLSX.writeFile(workbook, 'asset-import-template.xlsx'); +} + +async function openAssetImport() { + assetPageState.importReturnFocus = document.activeElement; + await ensureAssetProjects(true); + resetAssetImport(); + ensureAssetImportInteractions(); + if (typeof openAppModal === 'function') openAppModal('asset-import-modal', { focusEl: document.getElementById('asset-import-dropzone') }); + else document.getElementById('asset-import-modal').style.display = 'flex'; +} + +function closeAssetImport(force) { + if (assetPageState.importBusy && !force) return; + if (typeof closeAppModal === 'function') closeAppModal('asset-import-modal'); + else document.getElementById('asset-import-modal').style.display = 'none'; + const returnFocus = assetPageState.importReturnFocus; + if (returnFocus && typeof returnFocus.focus === 'function') requestAnimationFrame(() => returnFocus.focus()); +} + +function resetAssetImport() { + assetPageState.importRows = []; + assetPageState.importFileName = ''; + assetPageState.importBusy = false; + const file = document.getElementById('asset-import-file'); + if (file) file.value = ''; + const name = document.getElementById('asset-import-file-name'); + if (name) name.textContent = assetT('assets.fileNotSelected', '尚未选择文件'); + const preview = document.getElementById('asset-import-preview-section'); + if (preview) preview.hidden = true; + const body = document.getElementById('asset-import-preview-body'); + if (body) body.innerHTML = ''; + setAssetImportError(''); + setAssetImportBusy(false); +} + +function ensureAssetImportInteractions() { + if (assetPageState.importInteractionsReady) return; + assetPageState.importInteractionsReady = true; + const dropzone = document.getElementById('asset-import-dropzone'); + if (!dropzone) return; + ['dragenter', 'dragover'].forEach(type => dropzone.addEventListener(type, event => { + event.preventDefault(); + dropzone.classList.add('is-dragging'); + })); + ['dragleave', 'drop'].forEach(type => dropzone.addEventListener(type, event => { + event.preventDefault(); + dropzone.classList.remove('is-dragging'); + })); + dropzone.addEventListener('drop', event => handleAssetImportFile(event.dataTransfer?.files?.[0])); +} + +function setAssetImportError(message) { + const root = document.getElementById('asset-import-error'); + if (!root) return; + root.textContent = message || ''; + root.hidden = !message; +} + +function setAssetImportBusy(busy) { + assetPageState.importBusy = Boolean(busy); + const submit = document.getElementById('asset-import-submit'); + if (submit) submit.disabled = Boolean(busy) || !assetPageState.importRows.some(row => !row.error); + const dropzone = document.getElementById('asset-import-dropzone'); + if (dropzone) dropzone.disabled = Boolean(busy); +} + +async function handleAssetImportFile(file) { + setAssetImportError(''); + if (!file) return; + assetPageState.importRows = []; + assetPageState.importFileName = ''; + const previousPreview = document.getElementById('asset-import-preview-section'); + if (previousPreview) previousPreview.hidden = true; + const selectedName = document.getElementById('asset-import-file-name'); + if (selectedName) selectedName.textContent = file.name || assetT('assets.fileNotSelected', '尚未选择文件'); + setAssetImportBusy(false); + const extension = String(file.name || '').split('.').pop().toLowerCase(); + if (!['xlsx', 'csv'].includes(extension)) { + setAssetImportError(assetT('assets.fileTypeInvalid', '仅支持 .xlsx 和 .csv 文件')); + return; + } + if (file.size > ASSET_IMPORT_MAX_BYTES) { + setAssetImportError(assetT('assets.fileTooLarge', '文件不能超过 100 MB')); + return; + } + if (!window.XLSX) { + setAssetImportError(assetT('assets.spreadsheetUnavailable', '表格组件加载失败,请刷新后重试')); + return; + } + setAssetImportBusy(true); + try { + const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array', cellDates: false }); + const preferred = workbook.SheetNames.find(name => /^(assets?|资产)$/i.test(String(name).trim())); + const sheet = workbook.Sheets[preferred || workbook.SheetNames[0]]; + const matrix = sheet ? XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: false, blankrows: false }) : []; + parseAssetImportMatrix(matrix, file.name); + } catch (error) { + console.error('解析资产导入文件失败:', error); + setAssetImportError(error?.message || assetT('assets.fileParseFailed', '无法解析文件,请确认文件未损坏且格式正确')); + } finally { + setAssetImportBusy(false); + } +} + +function parseAssetImportMatrix(matrix, fileName) { + if (!Array.isArray(matrix) || matrix.length < 2) { + throw new Error(assetT('assets.fileHasNoData', '文件中没有可导入的数据')); + } + const headers = matrix[0].map(assetImportColumnForHeader); + if (!headers.some(Boolean)) { + throw new Error(assetT('assets.headerInvalid', '未识别到模板字段,请使用下载的模板填写')); + } + const dataRows = matrix.slice(1).filter(row => Array.isArray(row) && row.some(value => String(value ?? '').trim() !== '')); + if (!dataRows.length) throw new Error(assetT('assets.fileHasNoData', '文件中没有可导入的数据')); + if (dataRows.length > ASSET_IMPORT_MAX_ROWS) { + throw new Error(assetT('assets.tooManyRows', `数据不能超过 ${ASSET_IMPORT_MAX_ROWS} 行`, { count: ASSET_IMPORT_MAX_ROWS })); + } + const seen = new Map(); + assetPageState.importRows = dataRows.map((row, index) => { + const values = {}; + headers.forEach((column, columnIndex) => { + if (column && values[column] == null) values[column] = String(row[columnIndex] ?? '').trim(); + }); + const parsed = assetImportRecord(values, index + 2); + if (!parsed.error) { + const asset = parsed.asset; + const key = `${String(asset.domain || asset.ip || asset.host).toLowerCase()}|${asset.port || 0}|${asset.protocol || ''}`; + if (seen.has(key)) parsed.error = assetT('assets.duplicateFileRow', `与第 ${seen.get(key)} 行重复`, { row: seen.get(key) }); + else seen.set(key, parsed.rowNumber); + } + return parsed; + }); + assetPageState.importFileName = fileName; + const name = document.getElementById('asset-import-file-name'); + if (name) name.textContent = fileName; + renderAssetImportPreview(); +} + +function assetImportRecord(values, rowNumber) { + const fail = message => ({ rowNumber, values, asset: null, error: message }); + const target = values.target || values.host || values.domain || values.ip || ''; + if (!target) return fail(assetT('assets.importTargetRequired', '缺少目标地址')); + let parsed; + try { parsed = parseAssetEditorTarget(target); } + catch (error) { return fail(error.message); } + const rawPort = values.port; + const port = rawPort === '' || rawPort == null ? Number(parsed.port || 0) : Number(rawPort); + if (!Number.isInteger(port) || port < 0 || port > 65535) return fail(assetT('assets.portInvalid', '端口必须在 1–65535 之间')); + const ip = (values.ip || parsed.ip || '').toLowerCase(); + if (ip && !assetEditorIsIPv4(ip) && !assetEditorIsIPv6(ip)) return fail(assetT('assets.ipInvalid', 'IP 地址格式无效')); + const rawDomain = values.domain || parsed.domain || ''; + const domain = rawDomain ? assetEditorNormalizeDomain(rawDomain) : ''; + if (rawDomain && !domain) return fail(assetT('assets.domainInvalid', '域名格式无效')); + const protocol = (values.protocol || parsed.protocol || '').toLowerCase(); + if (protocol && !/^[a-z][a-z0-9+.-]{0,31}$/.test(protocol)) return fail(assetT('assets.protocolInvalid', '协议格式无效')); + const statuses = { active: 'active', inactive: 'inactive', '活跃': 'active', '停用': 'inactive' }; + const status = statuses[String(values.status || 'active').toLowerCase()]; + if (!status) return fail(assetT('assets.importStatusInvalid', '状态仅支持 active 或 inactive')); + const environments = { '': '', production: 'production', staging: 'staging', testing: 'testing', development: 'development', other: 'other', '生产': 'production', '预发布': 'staging', '测试': 'testing', '开发': 'development', '其他': 'other' }; + const environment = environments[String(values.environment || '').toLowerCase()]; + if (environment == null) return fail('环境值无效'); + const criticalities = { '': '', critical: 'critical', high: 'high', medium: 'medium', low: 'low', '核心': 'critical', '重要': 'high', '一般': 'medium', '低': 'low' }; + const criticality = criticalities[String(values.criticality || '').toLowerCase()]; + if (criticality == null) return fail('重要性值无效'); + let projectId = ''; + let projectName = ''; + if (values.project) { + const projectValue = String(values.project).trim().toLowerCase(); + const project = assetPageState.projects.find(item => String(item.id).toLowerCase() === projectValue || String(item.name).trim().toLowerCase() === projectValue); + if (!project) return fail(assetT('assets.importProjectNotFound', `项目不存在或无权访问:${values.project}`, { project: values.project })); + projectId = project.id; + projectName = project.name; + } + const tags = String(values.tags || '').split(/[,,;;|]/).map(tag => tag.trim()).filter(Boolean); + if (tags.length > 30 || tags.some(tag => Array.from(tag).length > 64)) return fail(assetT('assets.importTagsInvalid', '标签最多 30 个,单个标签最多 64 个字符')); + const asset = { + host: values.host || parsed.host || '', ip, domain, port, protocol, + title: values.title || '', server: values.server || '', country: values.country || '', + province: values.province || '', city: values.city || '', responsible_person: values.responsible_person || '', + department: values.department || '', business_system: values.business_system || '', environment, criticality, status, tags, + project_id: projectId, source: 'manual-import' + }; + const limits = { host: 500, title: 500, domain: 255, protocol: 255, server: 255, country: 255, province: 255, city: 255, responsible_person: 255, department: 255, business_system: 255 }; + const oversized = Object.keys(limits).find(key => Array.from(String(asset[key] || '')).length > limits[key]); + if (oversized) return fail(assetT('assets.importFieldTooLong', `字段 ${oversized} 内容过长`, { field: oversized })); + return { rowNumber, values, asset, projectName, error: '' }; +} + +function renderAssetImportPreview() { + const rows = assetPageState.importRows; + const valid = rows.filter(row => !row.error).length; + const invalid = rows.length - valid; + const preview = document.getElementById('asset-import-preview-section'); + if (preview) preview.hidden = false; + const summary = document.getElementById('asset-import-summary'); + if (summary) summary.textContent = assetT('assets.importPreviewSummary', `共 ${rows.length} 行,${valid} 行有效,${invalid} 行需修正`, { total: rows.length, valid, invalid }); + const body = document.getElementById('asset-import-preview-body'); + if (body) body.innerHTML = rows.slice(0, 100).map(row => { + const asset = row.asset || {}; + const target = row.values.target || row.values.host || row.values.domain || row.values.ip || '-'; + const result = row.error + ? `${escapeHtml(row.error)}` + : `${escapeHtml(assetT('assets.validationPassed', '通过'))}`; + return `${row.rowNumber}${escapeHtml(target)}${escapeHtml(row.projectName || row.values.project || '-')}${escapeHtml(asset.status || row.values.status || '-')}${result}`; + }).join(''); + const note = document.getElementById('asset-import-preview-note'); + if (note) note.textContent = rows.length > 100 ? assetT('assets.previewLimited', `仅展示前 100 行;提交时将处理全部 ${rows.length} 行`, { count: rows.length }) : ''; + const submit = document.getElementById('asset-import-submit'); + if (submit) { + submit.textContent = assetT('assets.importValidRowsCount', `导入 ${valid} 条有效数据`, { count: valid }); + submit.disabled = valid === 0 || assetPageState.importBusy; + } +} + +async function submitAssetImport() { + if (assetPageState.importBusy) return; + const assets = assetPageState.importRows.filter(row => !row.error).map(row => row.asset); + if (!assets.length) return; + setAssetImportError(''); + setAssetImportBusy(true); + try { + const response = await apiFetch('/api/assets/import', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ assets, source: 'manual-import', source_query: assetPageState.importFileName }) + }); + if (!response.ok) throw new Error(await assetEditorResponseError(response)); + const result = await response.json(); + const invalid = assetPageState.importRows.length - assets.length; + closeAssetImport(true); + await loadAssets(1); + if (typeof showInlineToast === 'function') { + showInlineToast(assetT('assets.bulkImportDone', `导入完成:新增 ${result.created} 条,更新 ${result.updated} 条,跳过 ${Number(result.skipped || 0) + invalid} 条`, { + created: result.created, updated: result.updated, skipped: Number(result.skipped || 0) + invalid + })); + } + } catch (error) { + setAssetImportError(assetT('assets.importFailed', '资产入库失败') + ': ' + error.message); + } finally { + setAssetImportBusy(false); + } +} + async function loadAssetOverview() { try { const response = await apiFetch('/api/assets/stats?days=' + assetOverviewDays); @@ -217,20 +543,147 @@ function renderAssetProtocolChart(items, total) { }).join(''); } +const ASSET_FILTER_FIELD_IDS = [ + 'asset-search', 'asset-status-filter', 'asset-project-filter', 'asset-risk-filter', 'asset-vuln-min-filter', + 'asset-protocol-filter', 'asset-port-filter', 'asset-source-filter', 'asset-tag-filter', 'asset-scan-filter', + 'asset-country-filter', 'asset-province-filter', 'asset-city-filter', 'asset-responsible-filter', + 'asset-department-filter', 'asset-business-filter', 'asset-environment-filter', 'asset-criticality-filter', + 'asset-first-seen-after-filter', 'asset-first-seen-before-filter', 'asset-last-seen-after-filter', + 'asset-last-seen-before-filter', 'asset-sort-filter' +]; + +function assetFilterValues() { + return Object.fromEntries(ASSET_FILTER_FIELD_IDS.map(id => [id, document.getElementById(id)?.value || ''])); +} + +function nextAssetFilterDay(value) { + if (!value) return ''; + const date = new Date(value + 'T00:00:00Z'); + date.setUTCDate(date.getUTCDate() + 1); + return date.toISOString().slice(0, 10); +} + +function buildAssetFilterParams() { + const params = new URLSearchParams(); + const map = { + 'asset-search': 'q', 'asset-status-filter': 'status', 'asset-project-filter': 'project_id', + 'asset-risk-filter': 'risk_level', 'asset-vuln-min-filter': 'min_vulnerabilities', + 'asset-protocol-filter': 'protocol', 'asset-port-filter': 'port', 'asset-source-filter': 'source', + 'asset-tag-filter': 'tag', 'asset-country-filter': 'country', 'asset-province-filter': 'province', + 'asset-city-filter': 'city', 'asset-responsible-filter': 'responsible_person', + 'asset-department-filter': 'department', 'asset-business-filter': 'business_system', + 'asset-environment-filter': 'environment', 'asset-criticality-filter': 'criticality', + 'asset-first-seen-after-filter': 'first_seen_after', 'asset-last-seen-after-filter': 'last_seen_after' + }; + Object.entries(map).forEach(([id, key]) => { + const value = document.getElementById(id)?.value.trim() || ''; + if (value) params.set(key, value); + }); + [ + ['asset-first-seen-before-filter', 'first_seen_before'], + ['asset-last-seen-before-filter', 'last_seen_before'] + ].forEach(([id, key]) => { + const value = nextAssetFilterDay(document.getElementById(id)?.value || ''); + if (value) params.set(key, value); + }); + const scan = document.getElementById('asset-scan-filter')?.value || ''; + if (scan === 'never' || scan === 'scanned') params.set('scan_state', scan); + else if (/^\d+$/.test(scan)) params.set('scan_overdue_days', scan); + const [sortBy, sortOrder] = (document.getElementById('asset-sort-filter')?.value || 'last_seen_at:desc').split(':'); + if (sortBy) params.set('sort_by', sortBy); + if (sortOrder) params.set('sort_order', sortOrder); + return params; +} + +function toggleAssetAdvancedFilters() { + const panel = document.getElementById('asset-advanced-filters'); + const button = document.getElementById('asset-advanced-toggle'); + if (!panel) return; + panel.hidden = !panel.hidden; + if (button) button.setAttribute('aria-expanded', String(!panel.hidden)); +} + +function resetAssetFilters() { + ASSET_FILTER_FIELD_IDS.forEach(id => { + const el = document.getElementById(id); + if (!el) return; + el.value = id === 'asset-sort-filter' ? 'last_seen_at:desc' : ''; + if (el.tagName === 'SELECT') syncAssetSelect(el); + }); + const saved = document.getElementById('asset-saved-view-select'); + if (saved) saved.value = ''; + clearAssetSelection(); + loadAssets(1); +} + +function readAssetSavedViews() { + try { + const views = JSON.parse(localStorage.getItem(ASSET_SAVED_VIEWS_KEY) || '[]'); + return Array.isArray(views) ? views : []; + } catch (error) { + return []; + } +} + +function renderAssetSavedViews(selectedName) { + const select = document.getElementById('asset-saved-view-select'); + if (!select) return; + const views = readAssetSavedViews(); + select.innerHTML = `` + views.map(view => ``).join(''); + select.value = selectedName || ''; + syncAssetSelect(select); +} + +function saveCurrentAssetView() { + const name = prompt('请输入筛选视图名称'); + if (!name || !name.trim()) return; + const cleanName = name.trim().slice(0, 60); + const views = readAssetSavedViews().filter(view => view.name !== cleanName); + views.push({ name: cleanName, values: assetFilterValues() }); + try { localStorage.setItem(ASSET_SAVED_VIEWS_KEY, JSON.stringify(views)); } catch (error) { return alert('保存筛选视图失败'); } + renderAssetSavedViews(cleanName); + if (typeof showInlineToast === 'function') showInlineToast('筛选视图已保存'); +} + +function applyAssetSavedView(name) { + if (!name) return; + const view = readAssetSavedViews().find(item => item.name === name); + if (!view) return; + Object.entries(view.values || {}).forEach(([id, value]) => { + const el = document.getElementById(id); + if (!el) return; + el.value = value || ''; + if (el.tagName === 'SELECT') syncAssetSelect(el); + }); + document.getElementById('asset-advanced-filters').hidden = false; + document.getElementById('asset-advanced-toggle')?.setAttribute('aria-expanded', 'true'); + clearAssetSelection(); + loadAssets(1); +} + +function deleteCurrentAssetView() { + const select = document.getElementById('asset-saved-view-select'); + const name = select?.value || ''; + if (!name || !confirm(`删除筛选视图“${name}”吗?`)) return; + const views = readAssetSavedViews().filter(view => view.name !== name); + try { localStorage.setItem(ASSET_SAVED_VIEWS_KEY, JSON.stringify(views)); } catch (error) { return; } + renderAssetSavedViews(); +} + async function loadAssets(page) { // 无显式页码表示进入资产库或点击顶部“刷新”,此时同步项目筛选项。 // 翻页、搜索等带页码的操作继续复用缓存,避免重复请求项目列表。 await ensureAssetProjects(page == null); assetPageState.page = Number(page || assetPageState.page || 1); - const params = new URLSearchParams({ page: assetPageState.page, page_size: assetPageState.pageSize }); - const q = document.getElementById('asset-search')?.value.trim() || ''; - const status = document.getElementById('asset-status-filter')?.value || ''; - const projectId = document.getElementById('asset-project-filter')?.value || ''; - if (q) params.set('q', q); - if (status) params.set('status', status); - if (projectId) params.set('project_id', projectId); + const filterParams = buildAssetFilterParams(); + const filterQuery = filterParams.toString(); + if (assetPageState.selectionQuery && assetPageState.selectionQuery !== filterQuery) clearAssetSelection(); + assetPageState.selectionQuery = filterQuery; + const params = new URLSearchParams(filterParams); + params.set('page', assetPageState.page); + params.set('page_size', assetPageState.pageSize); const body = document.getElementById('asset-table-body'); - if (body) body.innerHTML = '' + escapeHtml(assetT('common.loading', '加载中...')) + ''; + if (body) body.innerHTML = '' + escapeHtml(assetT('common.loading', '加载中...')) + ''; try { const response = await apiFetch('/api/assets?' + params.toString()); if (!response.ok) throw new Error(await response.text()); @@ -252,7 +705,7 @@ async function loadAssets(page) { assetPageState.items = []; assetPageState.total = 0; assetPageState.totalPages = 1; - if (body) body.innerHTML = '' + escapeHtml(assetT('assets.loadFailed', '加载资产失败')) + ''; + if (body) body.innerHTML = '' + escapeHtml(assetT('assets.loadFailed', '加载资产失败')) + ''; renderAssetPagination(); } } @@ -275,11 +728,43 @@ function assetRiskPresentation(level) { return { level: normalized, label: labels[normalized] }; } +function assetOwnershipMarkup(asset) { + const owner = String(asset.responsible_person || '').trim(); + const department = String(asset.department || '').trim(); + if (!owner && !department) { + return `${escapeHtml(assetT('assets.unassigned', '未分配'))}`; + } + const ownerLabel = assetT('assets.responsiblePerson', '负责人'); + const departmentLabel = assetT('assets.department', '部门'); + const title = [ + owner ? `${ownerLabel}: ${owner}` : '', + department ? `${departmentLabel}: ${department}` : '' + ].filter(Boolean).join(' · '); + const primary = owner || department; + return `
+ ${escapeHtml(primary)} + ${owner && department ? `${escapeHtml(department)}` : ''} +
`; +} + +function ensureAssetOwnershipColumn() { + const table = document.querySelector('#page-asset-library .asset-table'); + const headerRow = table?.querySelector('thead tr'); + if (!headerRow || headerRow.querySelector('[data-i18n="assets.ownership"]')) return; + const lastScanHeader = headerRow.querySelector('[data-i18n="assets.lastScan"]'); + if (!lastScanHeader) return; + const ownershipHeader = document.createElement('th'); + ownershipHeader.setAttribute('data-i18n', 'assets.ownership'); + ownershipHeader.textContent = assetT('assets.ownership', '归属'); + headerRow.insertBefore(ownershipHeader, lastScanHeader); + table.querySelectorAll('#asset-table-body td[colspan="9"]').forEach(cell => cell.setAttribute('colspan', '10')); +} + function renderAssetRows() { const body = document.getElementById('asset-table-body'); if (!body) return; if (!assetPageState.items.length) { - body.innerHTML = '' + escapeHtml(assetT('common.noData', '暂无数据')) + ''; + body.innerHTML = '' + escapeHtml(assetT('common.noData', '暂无数据')) + ''; return; } body.innerHTML = assetPageState.items.map((asset, index) => { @@ -290,10 +775,11 @@ function renderAssetRows() { const risk = assetRiskPresentation(asset.risk_level); const statusLabel = asset.status === 'inactive' ? assetT('assets.statusInactive', '停用') : assetT('assets.statusActive', '活跃'); return ` - + ${escapeHtml(service)} ${asset.project_name ? `${escapeHtml(asset.project_name)}` : '-'} + ${assetOwnershipMarkup(asset)} ${escapeHtml(lastScan)}${vulnerabilityCount > 0 ? `` : '0'} ${escapeHtml(risk.label)} ${escapeHtml(statusLabel)} @@ -307,7 +793,10 @@ function toggleAssetSelection(index, checked) { const asset = assetPageState.items[Number(index)]; if (!asset) return; if (checked) assetPageState.selected.set(asset.id, asset); - else assetPageState.selected.delete(asset.id); + else { + assetPageState.selected.delete(asset.id); + assetPageState.allMatchingSelected = false; + } updateAssetSelectionUI(); } @@ -316,22 +805,48 @@ function toggleAssetPageSelection(checked) { if (checked) assetPageState.selected.set(asset.id, asset); else assetPageState.selected.delete(asset.id); }); + if (!checked) assetPageState.allMatchingSelected = false; renderAssetRows(); updateAssetSelectionUI(); } function clearAssetSelection() { assetPageState.selected.clear(); + assetPageState.allMatchingSelected = false; + closeAssetBatchMenu(); renderAssetRows(); updateAssetSelectionUI(); } +function closeAssetBatchMenu() { + const menu = document.getElementById('asset-batch-more'); + if (menu) menu.open = false; +} + function updateAssetSelectionUI() { const count = assetPageState.selected.size; const actions = document.getElementById('asset-batch-actions'); const label = document.getElementById('asset-selected-count'); if (actions) actions.hidden = count === 0; if (label) label.textContent = assetT('assets.selectedCount', `已选择 ${count} 项`, { count }); + const selectAll = document.getElementById('asset-select-all-results'); + if (selectAll) { + selectAll.hidden = count === 0 || count >= assetPageState.total || assetPageState.allMatchingSelected; + selectAll.textContent = assetT('assets.selectAllResults', `选择全部 ${assetPageState.total} 项`, { count: assetPageState.total }); + } + const allSelected = document.getElementById('asset-all-results-selected'); + if (allSelected) { + allSelected.hidden = !assetPageState.allMatchingSelected; + allSelected.textContent = assetT('assets.allResultsSelected', `已选择当前筛选结果中的 ${count} 项`, { count }); + } + const merge = document.getElementById('asset-merge-selected'); + if (merge) { + merge.disabled = count < 2; + merge.setAttribute('aria-disabled', count < 2 ? 'true' : 'false'); + merge.title = count < 2 + ? assetT('assets.mergeRequiresMultiple', '请至少选择两个重复资产') + : ''; + } const pageToggle = document.getElementById('asset-select-page'); if (pageToggle) { const selectedOnPage = assetPageState.items.filter(asset => assetPageState.selected.has(asset.id)).length; @@ -340,6 +855,26 @@ function updateAssetSelectionUI() { } } +async function selectAllMatchingAssets() { + const button = document.getElementById('asset-select-all-results'); + if (button) button.disabled = true; + try { + const params = buildAssetFilterParams(); + const response = await apiFetch('/api/assets/selection?' + params.toString()); + if (!response.ok) throw new Error(await assetEditorResponseError(response)); + const data = await response.json(); + assetPageState.selected.clear(); + (data.assets || []).forEach(asset => assetPageState.selected.set(asset.id, asset)); + assetPageState.allMatchingSelected = true; + renderAssetRows(); + updateAssetSelectionUI(); + } catch (error) { + alert('选择全部结果失败: ' + error.message); + } finally { + if (button) button.disabled = false; + } +} + async function openAssetProjectModal() { const assets = Array.from(assetPageState.selected.values()); if (!assets.length) { @@ -396,6 +931,134 @@ async function submitAssetProjectBinding() { } } +function openAssetBulkEdit() { + const count = assetPageState.selected.size; + if (!count) return alert('请先选择资产'); + ['asset-bulk-status', 'asset-bulk-responsible', 'asset-bulk-department', 'asset-bulk-business', 'asset-bulk-environment', 'asset-bulk-criticality', 'asset-bulk-add-tags', 'asset-bulk-remove-tags'].forEach(id => { + const el = document.getElementById(id); + if (el) el.value = ''; + }); + document.getElementById('asset-bulk-edit-subtitle').textContent = `将修改 ${count} 个资产;空白字段保持原值`; + ['asset-bulk-status', 'asset-bulk-environment', 'asset-bulk-criticality'].forEach(syncAssetSelect); + if (typeof openAppModal === 'function') openAppModal('asset-bulk-edit-modal'); + else document.getElementById('asset-bulk-edit-modal').style.display = 'flex'; +} + +function closeAssetBulkEdit() { + if (typeof closeAppModal === 'function') closeAppModal('asset-bulk-edit-modal'); + else document.getElementById('asset-bulk-edit-modal').style.display = 'none'; +} + +function assetCommaValues(id) { + return (document.getElementById(id)?.value || '').split(/[,,]/).map(value => value.trim()).filter(Boolean); +} + +async function submitAssetBulkEdit() { + const ids = Array.from(assetPageState.selected.keys()); + if (!ids.length) return; + const body = { asset_ids: ids, add_tags: assetCommaValues('asset-bulk-add-tags'), remove_tags: assetCommaValues('asset-bulk-remove-tags') }; + const fields = { + status: 'asset-bulk-status', responsible_person: 'asset-bulk-responsible', department: 'asset-bulk-department', + business_system: 'asset-bulk-business', environment: 'asset-bulk-environment', criticality: 'asset-bulk-criticality' + }; + Object.entries(fields).forEach(([key, id]) => { + const value = document.getElementById(id)?.value.trim() || ''; + if (value) body[key] = value; + }); + if (Object.keys(body).length === 3 && body.add_tags.length === 0 && body.remove_tags.length === 0) return alert('请至少填写一项修改'); + const button = document.getElementById('asset-bulk-edit-submit'); + if (button) button.disabled = true; + try { + const response = await apiFetch('/api/assets/bulk', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + if (!response.ok) throw new Error(await assetEditorResponseError(response)); + closeAssetBulkEdit(); + clearAssetSelection(); + await loadAssets(assetPageState.page); + if (typeof showInlineToast === 'function') showInlineToast(`已更新 ${ids.length} 个资产`); + } catch (error) { + alert('批量编辑失败: ' + error.message); + } finally { + if (button) button.disabled = false; + } +} + +function assetExportRows(assets) { + return assets.map(asset => ({ + target: assetTargetLabel(asset), host: asset.host || '', ip: asset.ip || '', domain: asset.domain || '', port: asset.port || '', + protocol: asset.protocol || '', title: asset.title || '', server: asset.server || '', project: asset.project_name || '', + responsible_person: asset.responsible_person || '', department: asset.department || '', business_system: asset.business_system || '', + environment: asset.environment || '', criticality: asset.criticality || '', country: asset.country || '', province: asset.province || '', + city: asset.city || '', source: asset.source || '', status: asset.status || '', tags: (asset.tags || []).join(','), + risk_level: asset.risk_level || '', vulnerability_count: Number(asset.vulnerability_count || 0), + first_seen_at: asset.first_seen_at || '', last_seen_at: asset.last_seen_at || '', last_scan_at: asset.last_scan_at || '' + })); +} + +function assetCsvCell(value) { + const text = String(value == null ? '' : value); + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +function downloadAssetBlob(blob, filename) { + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + setTimeout(() => URL.revokeObjectURL(link.href), 0); +} + +function exportSelectedAssets(format) { + const rows = assetExportRows(Array.from(assetPageState.selected.values())); + if (!rows.length) return alert('请先选择资产'); + const stamp = new Date().toISOString().slice(0, 10); + if (format === 'csv') { + const headers = Object.keys(rows[0]); + const csv = '\uFEFF' + [headers.join(','), ...rows.map(row => headers.map(key => assetCsvCell(row[key])).join(','))].join('\r\n'); + downloadAssetBlob(new Blob([csv], { type: 'text/csv;charset=utf-8' }), `assets-${stamp}.csv`); + return; + } + if (!window.XLSX) return alert('表格组件加载失败,请刷新后重试'); + const workbook = XLSX.utils.book_new(); + const sheet = XLSX.utils.json_to_sheet(rows); + sheet['!autofilter'] = { ref: sheet['!ref'] }; + sheet['!cols'] = Object.keys(rows[0]).map(key => ({ wch: ['target', 'host', 'title'].includes(key) ? 30 : 16 })); + XLSX.utils.book_append_sheet(workbook, sheet, 'Assets'); + XLSX.writeFile(workbook, `assets-${stamp}.xlsx`); +} + +async function deleteSelectedAssets() { + const ids = Array.from(assetPageState.selected.keys()); + if (!ids.length || !confirm(`确定永久删除选中的 ${ids.length} 个资产吗?`)) return; + try { + const response = await apiFetch('/api/assets/batch-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ asset_ids: ids }) }); + if (!response.ok) throw new Error(await assetEditorResponseError(response)); + clearAssetSelection(); + await loadAssets(1); + if (typeof showInlineToast === 'function') showInlineToast(`已删除 ${ids.length} 个资产`); + } catch (error) { + alert('批量删除失败: ' + error.message); + } +} + +async function mergeSelectedAssets() { + const assets = Array.from(assetPageState.selected.values()); + if (assets.length < 2) return alert('请至少选择两个重复资产'); + const primary = assets[0]; + if (!confirm(`将保留“${assetTargetLabel(primary)}”作为主资产,并合并其余 ${assets.length - 1} 个资产。继续吗?`)) return; + try { + const response = await apiFetch('/api/assets/merge', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ asset_ids: assets.map(asset => asset.id), primary_id: primary.id }) + }); + if (!response.ok) throw new Error(await assetEditorResponseError(response)); + clearAssetSelection(); + await loadAssets(1); + if (typeof showInlineToast === 'function') showInlineToast(`已合并 ${assets.length} 个重复资产`); + } catch (error) { + alert('合并资产失败: ' + error.message); + } +} + function assetScanPromptDefault() { const placeholders = { asset_id: '{{asset_id}}', target: '{{target}}', host: '{{host}}', ip: '{{ip}}', domain: '{{domain}}', port: '{{port}}' }; return assetT('assets.defaultScanPrompt', '请对资产 {{target}}(资产ID:{{asset_id}})进行授权安全扫描,优先检查暴露服务、已知漏洞、弱口令和常见 Web 风险;通过 record_vulnerability 保存确认的漏洞,完成后调用 complete_asset_scan(id={{asset_id}}) 回写上次扫描时间和相关漏洞。', placeholders); @@ -609,6 +1272,11 @@ async function openAssetEditor(indexOrAsset) { document.getElementById('asset-edit-country').value = asset?.country || ''; document.getElementById('asset-edit-province').value = asset?.province || ''; document.getElementById('asset-edit-city').value = asset?.city || ''; + document.getElementById('asset-edit-responsible').value = asset?.responsible_person || ''; + document.getElementById('asset-edit-department').value = asset?.department || ''; + document.getElementById('asset-edit-business').value = asset?.business_system || ''; + document.getElementById('asset-edit-environment').value = asset?.environment || ''; + document.getElementById('asset-edit-criticality').value = asset?.criticality || ''; document.getElementById('asset-edit-title-value').value = asset?.title || ''; document.getElementById('asset-edit-tags').value = ''; assetPageState.editorTags = Array.from(new Set((asset?.tags || []).map(value => String(value).trim()).filter(Boolean))); @@ -616,6 +1284,8 @@ async function openAssetEditor(indexOrAsset) { document.getElementById('asset-edit-status').value = asset?.status || 'active'; syncAssetSelect('asset-edit-project'); syncAssetSelect('asset-edit-status'); + syncAssetSelect('asset-edit-environment'); + syncAssetSelect('asset-edit-criticality'); document.getElementById('asset-edit-target').value = assetEditorTargetFromAsset(asset); assetPageState.editorParsedTarget = document.getElementById('asset-edit-target').value.trim(); clearAssetEditorErrors(); @@ -824,6 +1494,9 @@ function collectAssetEditor() { title: document.getElementById('asset-edit-title-value').value.trim(), status: document.getElementById('asset-edit-status').value, source: existing.source || 'manual', source_query: existing.source_query || '', country: document.getElementById('asset-edit-country').value.trim(), province: document.getElementById('asset-edit-province').value.trim(), city: document.getElementById('asset-edit-city').value.trim(), + responsible_person: document.getElementById('asset-edit-responsible').value.trim(), department: document.getElementById('asset-edit-department').value.trim(), + business_system: document.getElementById('asset-edit-business').value.trim(), environment: document.getElementById('asset-edit-environment').value, + criticality: document.getElementById('asset-edit-criticality').value, project_id: document.getElementById('asset-edit-project').value, tags: assetPageState.editorTags.slice() }; @@ -1006,6 +1679,11 @@ function openAssetDetailRecord(asset) { assetDetailItem(assetT('assets.title', '标题/指纹'), escapeHtml(asset.title || ''), true), assetDetailItem(assetT('assets.server', '服务指纹'), escapeHtml(asset.server || '')), assetDetailItem(assetT('assets.location', '地区'), escapeHtml([asset.country, asset.province, asset.city].filter(Boolean).join(' / '))), + assetDetailItem(assetT('assets.responsiblePerson', '负责人'), escapeHtml(asset.responsible_person || '')), + assetDetailItem(assetT('assets.department', '部门'), escapeHtml(asset.department || '')), + assetDetailItem(assetT('assets.businessSystem', '业务系统'), escapeHtml(asset.business_system || '')), + assetDetailItem(assetT('assets.environment', '环境'), escapeHtml(asset.environment || '')), + assetDetailItem(assetT('assets.criticality', '重要性'), escapeHtml(asset.criticality || '')), assetDetailItem(assetT('assets.source', '来源'), escapeHtml(asset.source || '')), assetDetailItem(assetT('assets.sourceQuery', '来源查询'), asset.source_query ? `${escapeHtml(asset.source_query)}` : ''), assetDetailItem(assetT('assets.tagsLabel', '标签'), tags, true), @@ -1040,12 +1718,30 @@ window.submitAssetScan = submitAssetScan; window.toggleAssetSelection = toggleAssetSelection; window.toggleAssetPageSelection = toggleAssetPageSelection; window.clearAssetSelection = clearAssetSelection; +window.closeAssetBatchMenu = closeAssetBatchMenu; +window.selectAllMatchingAssets = selectAllMatchingAssets; +window.toggleAssetAdvancedFilters = toggleAssetAdvancedFilters; +window.resetAssetFilters = resetAssetFilters; +window.saveCurrentAssetView = saveCurrentAssetView; +window.applyAssetSavedView = applyAssetSavedView; +window.deleteCurrentAssetView = deleteCurrentAssetView; +window.openAssetBulkEdit = openAssetBulkEdit; +window.closeAssetBulkEdit = closeAssetBulkEdit; +window.submitAssetBulkEdit = submitAssetBulkEdit; +window.exportSelectedAssets = exportSelectedAssets; +window.deleteSelectedAssets = deleteSelectedAssets; +window.mergeSelectedAssets = mergeSelectedAssets; window.openAssetVulnerabilities = openAssetVulnerabilities; window.changeAssetPageSize = changeAssetPageSize; window.openAssetEditor = openAssetEditor; window.closeAssetEditor = closeAssetEditor; window.saveAsset = saveAsset; window.deleteAsset = deleteAsset; +window.openAssetImport = openAssetImport; +window.closeAssetImport = closeAssetImport; +window.downloadAssetTemplate = downloadAssetTemplate; +window.handleAssetImportFile = handleAssetImportFile; +window.submitAssetImport = submitAssetImport; window.importSelectedFofaAssets = importSelectedFofaAssets; window.importFofaRowAsset = importFofaRowAsset; window.openAssetDetail = openAssetDetail; @@ -1053,5 +1749,16 @@ window.openAssetDetailRecord = openAssetDetailRecord; window.closeAssetDetail = closeAssetDetail; window.editAssetFromDetail = editAssetFromDetail; -document.addEventListener('DOMContentLoaded', () => initAssetCustomSelects()); +document.addEventListener('DOMContentLoaded', () => { + ensureAssetOwnershipColumn(); + initAssetCustomSelects(); + renderAssetSavedViews(); + document.addEventListener('click', event => { + const menu = document.getElementById('asset-batch-more'); + if (menu?.open && event.target instanceof Node && !menu.contains(event.target)) closeAssetBatchMenu(); + }); + document.addEventListener('keydown', event => { + if (event.key === 'Escape') closeAssetBatchMenu(); + }); +}); document.addEventListener('languagechange', () => ASSET_CUSTOM_SELECT_IDS.forEach(syncAssetSelect)); diff --git a/web/static/js/chat.js b/web/static/js/chat.js index d35449d0..16580d5b 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -2377,11 +2377,19 @@ async function syncAssistantReasoningContentFromServer(backendMessageId, domAssi const msg = conv.messages.find((m) => m && String(m.id) === String(backendMessageId)); if (!msg || !msg.reasoningContent) return; setMessageReasoningContent(domAssistantId, msg.reasoningContent); - const pdRes = await apiFetch(`/api/messages/${encodeURIComponent(String(backendMessageId))}/process-details`); - const pdJson = await pdRes.json().catch(() => ({})); - const details = pdRes.ok && Array.isArray(pdJson.processDetails) ? pdJson.processDetails : []; - if (typeof renderProcessDetails === 'function') { - renderProcessDetails(domAssistantId, details); + // 最终回复到达后同样必须完整恢复过程详情;无参数接口默认仅返回前 50 条, + // 否则这里会把 task-events 恢复出的完整时间线再次覆盖成第一页。 + if (typeof window.loadProcessDetailsPaginated === 'function') { + await window.loadProcessDetailsPaginated(domAssistantId, String(backendMessageId)); + } else { + const pdRes = await apiFetch( + `/api/messages/${encodeURIComponent(String(backendMessageId))}/process-details?full=1` + ); + const pdJson = await pdRes.json().catch(() => ({})); + const details = pdRes.ok && Array.isArray(pdJson.processDetails) ? pdJson.processDetails : []; + if (typeof renderProcessDetails === 'function') { + renderProcessDetails(domAssistantId, details); + } } } catch (e) { console.warn('syncAssistantReasoningContentFromServer failed', e); @@ -4151,7 +4159,7 @@ async function prefetchLastAssistantProcessDetails() { await window.loadProcessDetailsPaginated(last.id, backendId); return; } - const res = await apiFetch('/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details'); + const res = await apiFetch('/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details?full=1'); const j = await res.json().catch(() => ({})); if (!res.ok || !Array.isArray(j.processDetails) || j.processDetails.length === 0) return; if (typeof renderProcessDetails === 'function') { @@ -9374,7 +9382,7 @@ function renderBatchConversations(filtered = null) { const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; - checkbox.className = 'batch-conversation-checkbox'; + checkbox.className = 'batch-conversation-checkbox theme-checkbox'; checkbox.dataset.conversationId = conv.id; checkbox.addEventListener('change', syncSelectAllBatchCheckbox); diff --git a/web/static/js/info-collect.js b/web/static/js/info-collect.js index 959fd2df..38eb310e 100644 --- a/web/static/js/info-collect.js +++ b/web/static/js/info-collect.js @@ -506,7 +506,7 @@ function renderFofaResults(payload) { // 表头(左:勾选列;右:操作列固定) const headerCells = [ - '', + '', ...visibleFields.map(f => `${escapeHtml(String(f))}`), '' + escapeHtml(_t('infoCollect.actions')) + '' ].join(''); @@ -525,7 +525,7 @@ function renderFofaResults(payload) { const encoded = encodeURIComponent(JSON.stringify(safeRow)); const encodedTarget = encodeURIComponent(target || ''); - const selectHtml = ''; + const selectHtml = ''; const cellsHtml = visibleFields.map(f => { const val = safeRow[f]; diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index b0bfac6c..833b23d3 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -1375,11 +1375,19 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut } const PROCESS_DETAILS_PAGE_SIZE = 50; +const processDetailsAutoLoadObservers = new WeakMap(); -function getProcessDetailsLoadMoreLabel(hasMore) { - if (!hasMore) return ''; - return (typeof window.t === 'function' ? window.t('common.loadMore') : '加载更多') + ' · ' + - (typeof window.t === 'function' ? window.t('chat.penetrationTestDetail') : '任务执行详情'); +function processDetailsContinuousLabel(kind) { + if (kind === 'older') { + return typeof window.t === 'function' ? window.t('chat.loadingEarlierDetails') : '正在加载更早记录…'; + } + if (kind === 'newer') { + return typeof window.t === 'function' ? window.t('chat.loadingLaterDetails') : '正在加载更新记录…'; + } + if (kind === 'retry') { + return typeof window.t === 'function' ? window.t('common.retry') : '加载失败,点击重试'; + } + return ''; } function updateProcessDetailsLoadMoreButton(assistantMessageId, backendMessageId, hasMore) { @@ -1389,79 +1397,171 @@ function updateProcessDetailsLoadMoreButton(assistantMessageId, backendMessageId }); } +function disconnectProcessDetailsAutoLoader(detailsContainer) { + if (!detailsContainer) return; + const old = processDetailsAutoLoadObservers.get(detailsContainer); + if (old) { + old.disconnect(); + processDetailsAutoLoadObservers.delete(detailsContainer); + } +} + +function scrollProcessDetailsToLatest(assistantMessageId, smooth) { + const detailsContainer = document.getElementById('process-details-' + assistantMessageId); + if (!detailsContainer) return; + const timeline = detailsContainer.querySelector('.progress-timeline'); + if (!timeline) return; + const behavior = smooth === false ? 'auto' : 'smooth'; + if (timeline.scrollHeight > timeline.clientHeight + 2) { + timeline.scrollTo({ top: timeline.scrollHeight, behavior: behavior }); + return; + } + const items = timeline.querySelectorAll('.timeline-item'); + if (!items.length) return; + const lastItem = items[items.length - 1]; + lastItem.scrollIntoView({ behavior: behavior, block: 'nearest' }); +} + +function updateProcessDetailsJumpLatestVisibility(detailsContainer) { + if (!detailsContainer) return; + const btn = detailsContainer.querySelector('.process-details-jump-latest'); + const timeline = detailsContainer.querySelector('.progress-timeline'); + if (!btn || !timeline) return; + const hasUnloadedNewer = detailsContainer.dataset.hasNext === '1'; + const awayFromTimelineBottom = timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop > 120; + btn.classList.toggle('visible', hasUnloadedNewer || awayFromTimelineBottom); +} + +async function requestProcessDetailsAutoPage(assistantMessageId, backendMessageId, direction, sentinel) { + const detailsContainer = document.getElementById('process-details-' + assistantMessageId); + if (!detailsContainer || !sentinel) return; + const loadingKey = direction === 'prev' ? 'loadingPrev' : 'loadingMore'; + const hasKey = direction === 'prev' ? 'hasPrev' : 'hasNext'; + if (detailsContainer.dataset[hasKey] !== '1' || detailsContainer.dataset[loadingKey] === '1') return; + detailsContainer.dataset[loadingKey] = '1'; + sentinel.classList.add('is-loading'); + sentinel.textContent = processDetailsContinuousLabel(direction === 'prev' ? 'older' : 'newer'); + try { + await loadProcessDetailsPaginated(assistantMessageId, backendMessageId, { + prepend: direction === 'prev', + append: direction === 'next', + autoLoadAll: false + }); + } catch (e) { + console.error('自动加载过程详情失败:', e); + sentinel.classList.remove('is-loading'); + sentinel.classList.add('is-error'); + sentinel.textContent = processDetailsContinuousLabel('retry'); + sentinel.onclick = function () { + sentinel.onclick = null; + sentinel.classList.remove('is-error'); + requestProcessDetailsAutoPage(assistantMessageId, backendMessageId, direction, sentinel); + }; + } finally { + detailsContainer.dataset[loadingKey] = '0'; + } +} + function updateProcessDetailsPaginationButtons(assistantMessageId, backendMessageId, pageState) { const detailsContainer = document.getElementById('process-details-' + assistantMessageId); if (!detailsContainer) return; + const timeline = detailsContainer.querySelector('.progress-timeline'); + if (!timeline) return; const state = pageState || {}; + detailsContainer.dataset.hasPrev = state.hasPrev ? '1' : '0'; + detailsContainer.dataset.hasNext = state.hasNext ? '1' : '0'; - let prevBtn = detailsContainer.querySelector('.process-details-load-prev-btn'); - if (!state.hasPrev) { - if (prevBtn) prevBtn.remove(); - } else { - if (!prevBtn) { - prevBtn = document.createElement('button'); - prevBtn.type = 'button'; - prevBtn.className = 'mcp-detail-btn process-details-load-prev-btn'; - const content = detailsContainer.querySelector('.process-details-content'); - if (content) { - detailsContainer.insertBefore(prevBtn, content); - } else { - detailsContainer.prepend(prevBtn); - } - } - const loadMoreText = typeof window.t === 'function' ? window.t('common.loadMore') : '加载更多'; - let prevPageText = typeof window.t === 'function' ? window.t('chat.previousPage') : '上一页'; - if (!prevPageText || prevPageText === 'chat.previousPage') prevPageText = '上一页'; - prevBtn.textContent = loadMoreText + ' · ' + prevPageText; - prevBtn.disabled = false; - prevBtn.onclick = async () => { - if (detailsContainer.dataset.loadingPrev === '1') return; - detailsContainer.dataset.loadingPrev = '1'; - prevBtn.disabled = true; - prevBtn.textContent = typeof window.t === 'function' ? window.t('common.loading') : '加载中…'; - try { - await loadProcessDetailsPaginated(assistantMessageId, backendMessageId, { - prepend: true, - autoLoadAll: false - }); - } finally { - detailsContainer.dataset.loadingPrev = '0'; - } - }; + detailsContainer.querySelectorAll('.process-details-load-prev-btn, .process-details-load-more-btn').forEach(function (el) { + el.remove(); + }); + timeline.querySelectorAll('.process-details-auto-sentinel').forEach(function (el) { + el.remove(); + }); + disconnectProcessDetailsAutoLoader(detailsContainer); + + let topSentinel = null; + let bottomSentinel = null; + if (state.hasPrev) { + topSentinel = document.createElement('button'); + topSentinel.type = 'button'; + topSentinel.className = 'process-details-auto-sentinel process-details-auto-sentinel--top'; + topSentinel.setAttribute('aria-label', processDetailsContinuousLabel('older')); + topSentinel.textContent = processDetailsContinuousLabel('older'); + timeline.prepend(topSentinel); + } + if (state.hasNext) { + bottomSentinel = document.createElement('button'); + bottomSentinel.type = 'button'; + bottomSentinel.className = 'process-details-auto-sentinel process-details-auto-sentinel--bottom'; + bottomSentinel.setAttribute('aria-label', processDetailsContinuousLabel('newer')); + bottomSentinel.textContent = processDetailsContinuousLabel('newer'); + timeline.appendChild(bottomSentinel); } - let nextBtn = detailsContainer.querySelector('.process-details-load-more-btn'); - if (!state.hasNext) { - if (nextBtn) nextBtn.remove(); - return; + let jumpBtn = detailsContainer.querySelector('.process-details-jump-latest'); + if (!jumpBtn) { + jumpBtn = document.createElement('button'); + jumpBtn.type = 'button'; + jumpBtn.className = 'process-details-jump-latest'; + jumpBtn.textContent = typeof window.t === 'function' ? window.t('chat.backToLatestProgress') : '↓ 回到最新进度'; + detailsContainer.appendChild(jumpBtn); } - if (!nextBtn) { - nextBtn = document.createElement('button'); - nextBtn.type = 'button'; - nextBtn.className = 'mcp-detail-btn process-details-load-more-btn'; - detailsContainer.appendChild(nextBtn); - } - nextBtn.textContent = getProcessDetailsLoadMoreLabel(true); - nextBtn.disabled = false; - nextBtn.onclick = async () => { - if (detailsContainer.dataset.loadingMore === '1') return; - detailsContainer.dataset.loadingMore = '1'; - nextBtn.disabled = true; - nextBtn.textContent = typeof window.t === 'function' ? window.t('common.loading') : '加载中…'; - try { + jumpBtn.onclick = async function () { + if (detailsContainer.dataset.hasNext === '1') { await loadProcessDetailsPaginated(assistantMessageId, backendMessageId, { - append: true, - autoLoadAll: false + autoLoadAll: false, + initialLatest: true }); - } finally { - detailsContainer.dataset.loadingMore = '0'; } + requestAnimationFrame(function () { + scrollProcessDetailsToLatest(assistantMessageId, true); + updateProcessDetailsJumpLatestVisibility(detailsContainer); + }); }; + + if (timeline.dataset.continuousScrollBound !== '1') { + timeline.dataset.continuousScrollBound = '1'; + timeline.addEventListener('scroll', function () { + updateProcessDetailsJumpLatestVisibility(detailsContainer); + }, { passive: true }); + } + + if (typeof IntersectionObserver === 'function' && (topSentinel || bottomSentinel)) { + const root = document.getElementById('chat-messages') || null; + const observer = new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + if (detailsContainer.dataset.autoLoadSuspended === '1') return; + if (entry.target === topSentinel) { + requestProcessDetailsAutoPage(assistantMessageId, backendMessageId, 'prev', topSentinel); + } else if (entry.target === bottomSentinel) { + requestProcessDetailsAutoPage(assistantMessageId, backendMessageId, 'next', bottomSentinel); + } + }); + }, { root: root, rootMargin: '180px 0px', threshold: 0.01 }); + if (topSentinel) observer.observe(topSentinel); + if (bottomSentinel) observer.observe(bottomSentinel); + processDetailsAutoLoadObservers.set(detailsContainer, observer); + } else { + if (topSentinel) { + topSentinel.textContent = typeof window.t === 'function' ? window.t('common.loadMore') : '加载更早记录'; + topSentinel.onclick = function () { + requestProcessDetailsAutoPage(assistantMessageId, backendMessageId, 'prev', topSentinel); + }; + } + if (bottomSentinel) { + bottomSentinel.textContent = typeof window.t === 'function' ? window.t('common.loadMore') : '加载更新记录'; + bottomSentinel.onclick = function () { + requestProcessDetailsAutoPage(assistantMessageId, backendMessageId, 'next', bottomSentinel); + }; + } + } + updateProcessDetailsJumpLatestVisibility(detailsContainer); } /** * 分页加载过程详情并增量渲染。默认全量加载供恢复流程使用; - * 用户手动展开时传 autoLoadAll=false,只加载一页并展示“加载更多”。 + * 用户手动展开时由任务状态选择首个历史页或最新页,滚动到边界后自动加载相邻页。 */ async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId, options) { if (!assistantMessageId || !backendMessageId || typeof apiFetch !== 'function' || typeof renderProcessDetails !== 'function') { @@ -1481,6 +1581,21 @@ async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId, ? parseInt(detailsContainer.dataset.nextOffset, 10) || 0 : 0; const anchorId = opts.anchorId != null ? String(opts.anchorId).trim() : ''; + if (opts.initialLatest && !prepend && !opts.append && !anchorId) { + if (detailsContainer) { + // 初页渲染完成前禁止顶部哨兵抢先触发;定位到底部后再开放自动加载。 + detailsContainer.dataset.autoLoadSuspended = '1'; + } + const summaryRes = await apiFetch( + '/api/messages/' + encodeURIComponent(String(backendMessageId)) + '/process-details?summary=1' + ); + const summaryJSON = await summaryRes.json().catch(() => ({})); + if (!summaryRes.ok) { + throw new Error((summaryJSON && summaryJSON.error) ? summaryJSON.error : String(summaryRes.status)); + } + const total = summaryJSON && summaryJSON.summary && Number(summaryJSON.summary.total); + offset = Number.isFinite(total) ? Math.max(0, total - PAGE) : 0; + } let isFirst = !opts.append; while (true) { const params = new URLSearchParams(); @@ -1507,6 +1622,9 @@ async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId, markLoaded: autoLoadAll ? !hasMore : true, toolExecutions: toolExecutions }); + // renderProcessDetails 对大页分帧渲染;等待一帧后再放置顶部/底部哨兵, + // 避免哨兵被后续批次插到时间线中间。 + await new Promise((resolve) => requestAnimationFrame(resolve)); const responseOffset = j && typeof j.offset === 'number' ? j.offset : offset; const total = j && typeof j.total === 'number' ? j.total : responseOffset + details.length; const nextOffset = prepend && existingNextOffset > 0 @@ -1519,6 +1637,7 @@ async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId, detailsContainer.dataset.loaded = hasMore ? 'partial' : '1'; detailsContainer.dataset.prevOffset = String(prevOffset); detailsContainer.dataset.nextOffset = String(nextOffset); + detailsContainer.dataset.total = String(total); } updateProcessDetailsPaginationButtons(assistantMessageId, backendMessageId, { hasPrev: !autoLoadAll && responseOffset > 0, @@ -1530,10 +1649,38 @@ async function loadProcessDetailsPaginated(assistantMessageId, backendMessageId, isFirst = false; await new Promise((resolve) => requestAnimationFrame(resolve)); } + if (opts.initialLatest) { + requestAnimationFrame(function () { + scrollProcessDetailsToLatest(assistantMessageId, false); + if (detailsContainer) { + delete detailsContainer.dataset.autoLoadSuspended; + } + }); + } else if (opts.initialStart) { + requestAnimationFrame(function () { + const container = document.getElementById('process-details-' + assistantMessageId); + const timeline = container && container.querySelector('.progress-timeline'); + if (timeline) timeline.scrollTop = 0; + }); + } } window.loadProcessDetailsPaginated = loadProcessDetailsPaginated; +function shouldInitiallyOpenProcessDetailsAtLatest(assistantMessageId, detailsContainer) { + if (!detailsContainer) return false; + // task-events 恢复流会明确给当前详情容器打 is-streaming 标记。 + if (detailsContainer.classList.contains('is-streaming')) return true; + try { + const replay = window.__csTaskEventStream; + if (replay && replay.active && String(replay.assistantDomId || '') === String(assistantMessageId || '')) { + return true; + } + } catch (e) { /* ignore */ } + // 其余情况按终态/历史详情处理,从第一条开始,便于顺序复盘。 + return false; +} + function resolveEventBackendMessageId(eventData) { if (!eventData || typeof eventData !== 'object') return ''; const raw = eventData.messageId != null ? eventData.messageId : eventData.assistantMessageId; @@ -1549,7 +1696,12 @@ function triggerLazyProcessDetailsLoad(assistantMessageId, backendMessageId, det if (timeline) { timeline.innerHTML = '
' + ((typeof window.t === 'function') ? window.t('common.loading') : '加载中…') + '
'; } - loadProcessDetailsPaginated(assistantMessageId, backendMessageId, { autoLoadAll: false }) + const openAtLatest = shouldInitiallyOpenProcessDetailsAtLatest(assistantMessageId, detailsContainer); + loadProcessDetailsPaginated(assistantMessageId, backendMessageId, { + autoLoadAll: false, + initialLatest: openAtLatest, + initialStart: !openAtLatest + }) .catch((e) => { console.error('加载过程详情失败:', e); const tl = detailsContainer.querySelector('.progress-timeline'); @@ -3146,7 +3298,7 @@ async function restoreWorkflowHitlInlineForConversation(conversationId) { if (typeof loadProcessDetailsPaginated === 'function') { await loadProcessDetailsPaginated(clientMsgId, backendMsgId); } else if (typeof apiFetch === 'function' && backendMsgId) { - const res = await apiFetch('/api/messages/' + encodeURIComponent(backendMsgId) + '/process-details'); + const res = await apiFetch('/api/messages/' + encodeURIComponent(backendMsgId) + '/process-details?full=1'); const j = await res.json().catch(function () { return {}; }); if (res.ok && typeof renderProcessDetails === 'function') { renderProcessDetails(clientMsgId, (j && Array.isArray(j.processDetails)) ? j.processDetails : []); @@ -3278,7 +3430,7 @@ async function restoreHitlInlineForConversation(conversationId) { if (typeof loadProcessDetailsPaginated === 'function') { await loadProcessDetailsPaginated(clientMsgId, backendMsgId); } else { - const res = await apiFetch('/api/messages/' + encodeURIComponent(backendMsgId) + '/process-details'); + const res = await apiFetch('/api/messages/' + encodeURIComponent(backendMsgId) + '/process-details?full=1'); const j = await res.json().catch(function () { return {}; }); if (!res.ok) throw new Error((j && j.error) ? j.error : String(res.status)); const details = (j && Array.isArray(j.processDetails)) ? j.processDetails : []; @@ -3356,12 +3508,20 @@ async function refreshLastAssistantProcessDetails(conversationId) { wasExpanded = !!(tl && tl.classList.contains('expanded')); } try { - const res = await apiFetch('/api/messages/' + encodeURIComponent(backendId) + '/process-details'); - const j = await res.json().catch(function () { return {}; }); - if (!res.ok) return; - const details = Array.isArray(j.processDetails) ? j.processDetails : []; - if (typeof renderProcessDetails === 'function') { - renderProcessDetails(clientId, details); + // 恢复流程必须遍历全部分页。直接请求无参数接口只会返回最早 50 条, + // 长任务刷新后会表现为“旧轮次 → 当前实时轮次”的中间历史缺失。 + if (typeof loadProcessDetailsPaginated === 'function') { + await loadProcessDetailsPaginated(clientId, backendId); + } else { + const res = await apiFetch( + '/api/messages/' + encodeURIComponent(backendId) + '/process-details?full=1' + ); + const j = await res.json().catch(function () { return {}; }); + if (!res.ok) return; + const details = Array.isArray(j.processDetails) ? j.processDetails : []; + if (typeof renderProcessDetails === 'function') { + renderProcessDetails(clientId, details); + } } if (wasExpanded) { expandProcessDetailsTimeline(clientId); @@ -3407,15 +3567,22 @@ async function attachRunningTaskEventStream(conversationId) { if (!asEl || !asEl.id) return false; const backendId = asEl.dataset && asEl.dataset.backendMessageId; if (backendId && typeof renderProcessDetails === 'function') { - const res = await apiFetch('/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details'); - const jd = await res.json().catch(function () { return {}; }); - if (res.ok && Array.isArray(jd.processDetails)) { - renderProcessDetails(asEl.id, jd.processDetails); - // renderProcessDetails 会重建时间线节点,需重新挂载 HITL 审批入口 - if (typeof window.restoreHitlInlineForConversation === 'function') { - await window.restoreHitlInlineForConversation(conversationId); + // 运行中会话可能远超默认 50 条;完整补齐数据库历史后再接实时事件。 + if (typeof loadProcessDetailsPaginated === 'function') { + await loadProcessDetailsPaginated(asEl.id, String(backendId)); + } else { + const res = await apiFetch( + '/api/messages/' + encodeURIComponent(String(backendId)) + '/process-details?full=1' + ); + const jd = await res.json().catch(function () { return {}; }); + if (res.ok && Array.isArray(jd.processDetails)) { + renderProcessDetails(asEl.id, jd.processDetails); } } + // 历史重绘会重建时间线节点,需重新挂载 HITL 审批入口。 + if (typeof window.restoreHitlInlineForConversation === 'function') { + await window.restoreHitlInlineForConversation(conversationId); + } } expandProcessDetailsTimeline(asEl.id); @@ -3482,7 +3649,18 @@ async function attachRunningTaskEventStream(conversationId) { } if (typeof loadActiveTasks === 'function') loadActiveTasks(); if (replaySawDone && typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) { + const replayTimeline = document.getElementById('process-details-' + asEl.id + '-timeline'); + const keepExpanded = !!(replayTimeline && replayTimeline.classList.contains('expanded')); await window.loadConversation(conversationId); + // loadConversation 使用轻量消息接口,会把详情重新置为懒加载状态; + // 任务终态再从 DB 全量对账一次,补回订阅建立期间可能错过的事件。 + await refreshLastAssistantProcessDetails(conversationId); + if (keepExpanded) { + const finalAssistant = findLastAssistantMessageElInChat(); + if (finalAssistant && finalAssistant.id) { + expandProcessDetailsTimeline(finalAssistant.id); + } + } } return true; } catch (e) { @@ -6236,7 +6414,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') { html: ` - + ${toolName} ${escapeHtml(statusLabel)} @@ -6269,7 +6447,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') { const headerHtml = ` - + ${escapeHtml(colTool)} ${escapeHtml(colStatus)} diff --git a/web/static/js/projects.js b/web/static/js/projects.js index ddadabe3..d7d5e250 100644 --- a/web/static/js/projects.js +++ b/web/static/js/projects.js @@ -8,6 +8,20 @@ let currentProjectId = null; let currentProjectUpdatedAt = null; let currentProjectTab = 'facts'; let currentProjectAssets = []; +const PROJECT_ASSETS_PAGE_SIZE_KEY = 'cyberstrike.project_assets_page_size'; +let projectAssetsPagination = { + page: 1, + pageSize: (() => { + try { + const size = Number(localStorage.getItem(PROJECT_ASSETS_PAGE_SIZE_KEY)); + return [10, 20, 50, 100].includes(size) ? size : 20; + } catch (e) { + return 20; + } + })(), + total: 0, + totalPages: 1, +}; const projectNameById = {}; let _projectsListReady = false; let _projectsFetchPromise = null; @@ -974,6 +988,7 @@ function updateProjectStats(stats) { async function selectProject(id) { currentProjectId = id; + projectAssetsPagination.page = 1; const searchEl = document.getElementById('project-facts-search'); const catEl = document.getElementById('project-facts-filter-category'); const confEl = document.getElementById('project-facts-filter-confidence'); @@ -1032,24 +1047,40 @@ function switchProjectTab(tab) { if (tab === 'vulns') loadProjectVulnerabilities(); } -async function loadProjectAssets() { +async function loadProjectAssets(page) { const tbody = document.getElementById('project-assets-tbody'); const countEl = document.getElementById('project-assets-count'); if (!tbody || !currentProjectId) return; + const requestedPage = Math.max(1, Number(page || projectAssetsPagination.page || 1)); + projectAssetsPagination.page = requestedPage; tbody.innerHTML = `${escapeHtml(tpFmt('common.loading', '加载中...'))}`; - const qs = new URLSearchParams({ project_id: currentProjectId, page: '1', page_size: '100' }); + const qs = new URLSearchParams({ + project_id: currentProjectId, + page: String(requestedPage), + page_size: String(projectAssetsPagination.pageSize), + }); const res = await apiFetch(`/api/assets?${qs.toString()}`); if (!res.ok) { currentProjectAssets = []; + projectAssetsPagination.total = 0; + projectAssetsPagination.totalPages = 1; if (countEl) countEl.textContent = '0'; tbody.innerHTML = `${escapeHtml(tpFmt('common.loadFailed', '加载失败'))}`; + renderProjectAssetsPagination(); return; } const data = await res.json(); currentProjectAssets = data.assets || []; + projectAssetsPagination.page = Number(data.page || requestedPage); + projectAssetsPagination.total = Number(data.total || 0); + projectAssetsPagination.totalPages = Math.max(1, Number(data.total_pages || 1)); + if (projectAssetsPagination.page > projectAssetsPagination.totalPages) { + return loadProjectAssets(projectAssetsPagination.totalPages); + } if (countEl) countEl.textContent = tpFmt('projects.assetCount', `${data.total || 0} 个资产`, { count: data.total || 0 }); if (!currentProjectAssets.length) { tbody.innerHTML = `${escapeHtml(tpFmt('projects.noBoundAssets', '暂无绑定到此项目的资产'))}`; + renderProjectAssetsPagination(); return; } tbody.innerHTML = currentProjectAssets.map((asset, index) => { @@ -1068,6 +1099,47 @@ async function loadProjectAssets() {
`; }).join(''); + renderProjectAssetsPagination(); + const tableWrap = document.querySelector('#project-panel-assets .projects-table-wrap'); + if (tableWrap) tableWrap.scrollTop = 0; +} + +function renderProjectAssetsPagination() { + const root = document.getElementById('project-assets-pagination'); + if (!root) return; + const { page, pageSize, total, totalPages } = projectAssetsPagination; + const start = total === 0 ? 0 : (page - 1) * pageSize + 1; + const end = total === 0 ? 0 : Math.min(page * pageSize, total); + const atFirst = page <= 1 || total === 0; + const atLast = page >= totalPages || total === 0; + root.innerHTML = ``; +} + +function changeProjectAssetsPageSize(value) { + const size = Number(value); + if (![10, 20, 50, 100].includes(size)) return; + projectAssetsPagination.pageSize = size; + projectAssetsPagination.page = 1; + try { + localStorage.setItem(PROJECT_ASSETS_PAGE_SIZE_KEY, String(size)); + } catch (e) { /* ignore */ } + loadProjectAssets(1); } function openProjectAssetDetail(index) { diff --git a/web/static/js/rbac-guards.js b/web/static/js/rbac-guards.js index d889db08..956d6804 100644 --- a/web/static/js/rbac-guards.js +++ b/web/static/js/rbac-guards.js @@ -115,6 +115,8 @@ exportFofaResults: 'fofa:execute', importSelectedFofaAssets: 'asset:write', importFofaRowAsset: 'asset:write', + openAssetImport: 'asset:write', + submitAssetImport: 'asset:write', saveAsset: 'asset:write', deleteAsset: 'asset:delete', diff --git a/web/static/js/router.js b/web/static/js/router.js index 759ab9c8..4314aa8f 100644 --- a/web/static/js/router.js +++ b/web/static/js/router.js @@ -1,5 +1,5 @@ // 页面路由管理 -let currentPage = 'dashboard'; +let currentPage = null; /** chat、漏洞管理页在切换时保留当前 hash 上的查询串(如 ?conversation= / ?conversation_id=) */ function buildHashForPage(pageId) { @@ -96,6 +96,19 @@ function initRouter() { // 切换页面 function switchPage(pageId) { + const targetPage = document.getElementById(`page-${pageId}`); + if (!targetPage) return; + + // 导航点击会修改 hash,随后浏览器还会触发 hashchange。 + // 同一页面已经激活时不再重复初始化,避免接口重复请求和页面二次重绘。 + if (currentPage === pageId && targetPage.classList.contains('active')) { + const currentHash = buildHashForPage(pageId); + if (window.location.hash.slice(1) !== currentHash) { + window.location.hash = currentHash; + } + return; + } + if (typeof window.syncC2NavOnceFromServer === 'function') { void window.syncC2NavOnceFromServer(); } @@ -105,25 +118,22 @@ function switchPage(pageId) { }); // 显示目标页面 - const targetPage = document.getElementById(`page-${pageId}`); - if (targetPage) { - targetPage.classList.add('active'); - currentPage = pageId; + targetPage.classList.add('active'); + currentPage = pageId; - const newHash = buildHashForPage(pageId); - if (window.location.hash.slice(1) !== newHash) { - window.location.hash = newHash; - } + const newHash = buildHashForPage(pageId); + if (window.location.hash.slice(1) !== newHash) { + window.location.hash = newHash; + } - // 更新导航状态 - updateNavState(pageId); + // 更新导航状态 + updateNavState(pageId); - // 页面特定的初始化 - initPage(pageId); + // 页面特定的初始化 + initPage(pageId); - if (typeof applyRBACToUI === 'function') { - applyRBACToUI(targetPage); - } + if (typeof applyRBACToUI === 'function') { + applyRBACToUI(targetPage); } } window.switchPage = switchPage; diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 78b36fcf..f8acd32c 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -503,6 +503,8 @@ let toolsPagination = { total: 0, totalPages: 0 }; +let toolsLoadController = null; +let toolsLoadSequence = 0; let c2NavSyncedOnce = false; @@ -705,10 +707,8 @@ async function loadConfig(loadTools = true, options = {}) { // 填充FOFA配置 const fofa = currentConfig.fofa || {}; - const fofaEmailEl = document.getElementById('fofa-email'); const fofaKeyEl = document.getElementById('fofa-api-key'); const fofaBaseUrlEl = document.getElementById('fofa-base-url'); - if (fofaEmailEl) fofaEmailEl.value = fofa.email || ''; if (fofaKeyEl) fofaKeyEl.value = fofa.api_key || ''; if (fofaBaseUrlEl) fofaBaseUrlEl.value = fofa.base_url || ''; @@ -1080,17 +1080,28 @@ async function loadToolsList(page = 1, searchKeyword = '', options = {}) { // 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符 if (window.i18nReady) await window.i18nReady; const toolsList = document.getElementById('tools-list'); + const requestSequence = ++toolsLoadSequence; - // 显示加载状态 + // 新请求接管列表,取消仍在进行的旧请求,避免连续筛选/切页时旧响应覆盖新结果。 + if (toolsLoadController) { + toolsLoadController.abort(); + } + const controller = new AbortController(); + toolsLoadController = controller; + + // 清理 DOM 之前先保留用户尚未保存的勾选状态。 + saveCurrentPageToolStates(); + + // 首次加载才显示占位;后续刷新保留旧列表,避免整块内容闪烁和布局跳动。 if (toolsList) { - // 清空整个容器,包括可能存在的分页控件 - toolsList.innerHTML = '
⏳ ' + (typeof window.t === 'function' ? window.t('mcp.loadingTools') : '正在加载工具列表...') + '
'; + toolsList.setAttribute('aria-busy', 'true'); + if (!toolsList.querySelector('.tool-item')) { + toolsList.innerHTML = '
⏳ ' + (typeof window.t === 'function' ? window.t('mcp.loadingTools') : '正在加载工具列表...') + '
'; + } } + let timeoutId = null; try { - // 在加载新页面之前,先保存当前页的状态到全局映射 - saveCurrentPageToolStates(); - const pageSize = toolsPagination.pageSize; let url = `/api/config/tools?page=${page}&page_size=${pageSize}`; if (searchKeyword) { @@ -1107,13 +1118,11 @@ async function loadToolsList(page = 1, searchKeyword = '', options = {}) { } // 使用较短的超时时间(10秒),避免长时间等待 - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); + timeoutId = setTimeout(() => controller.abort(), 10000); const response = await apiFetch(url, { signal: controller.signal }); - clearTimeout(timeoutId); if (!response.ok) { if (typeof readApiError === 'function') { @@ -1123,6 +1132,8 @@ async function loadToolsList(page = 1, searchKeyword = '', options = {}) { } const result = await response.json(); + if (requestSequence !== toolsLoadSequence) return; + allTools = result.tools || []; toolsPagination = { page: result.page || page, @@ -1150,6 +1161,9 @@ async function loadToolsList(page = 1, searchKeyword = '', options = {}) { renderExternalMcpFilterChip(); updateExternalMcpCardSelection(); } catch (error) { + // 被后续请求替代属于正常控制流,不显示错误,也不覆盖新请求的界面。 + if (controller.signal.aborted && requestSequence !== toolsLoadSequence) return; + console.error('加载工具列表失败:', error); if (toolsList) { const isTimeout = error.name === 'AbortError' || error.message.includes('timeout'); @@ -1158,6 +1172,12 @@ async function loadToolsList(page = 1, searchKeyword = '', options = {}) { : (typeof window.t === 'function' ? window.t('mcp.loadToolsFailed') : '加载工具列表失败') + ': ' + escapeHtml(error.message); toolsList.innerHTML = `
${errorMsg}
`; } + } finally { + if (timeoutId !== null) clearTimeout(timeoutId); + if (requestSequence === toolsLoadSequence) { + if (toolsList) toolsList.removeAttribute('aria-busy'); + if (toolsLoadController === controller) toolsLoadController = null; + } } } @@ -1287,13 +1307,13 @@ function renderToolsList() { const checkboxId = `tool-${escapeHtml(toolKey).replace(/::/g, '--')}`; toolItem.innerHTML = ` - +
${escapeHtml(tool.name)} ${externalBadge} ${alwaysVisibleLocked ? `${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinLabel') : '内置默认'}` : ''} @@ -1883,7 +1903,6 @@ async function applySettings() { }, vision: visionPayload, fofa: { - email: document.getElementById('fofa-email')?.value.trim() || '', api_key: document.getElementById('fofa-api-key')?.value.trim() || '', base_url: document.getElementById('fofa-base-url')?.value.trim() || '' }, @@ -3094,6 +3113,23 @@ async function fetchExternalMCPs() { // MCP 管理页定时刷新外部 MCP 状态(感知后台断连/自动重连) let externalMcpPollTimer = null; const EXTERNAL_MCP_POLL_INTERVAL_MS = 8000; +let externalMcpRenderSignature = ''; + +function renderExternalMCPData(data, forceRender = false) { + const servers = data.servers || {}; + const stats = data.stats || {}; + const signature = JSON.stringify({ servers, stats }); + + if (!forceRender && signature === externalMcpRenderSignature) { + updateExternalMcpCardSelection(); + return false; + } + + externalMcpRenderSignature = signature; + renderExternalMCPList(servers); + renderExternalMCPStats(stats); + return true; +} function startExternalMcpPoll() { stopExternalMcpPoll(); @@ -3118,13 +3154,12 @@ function stopExternalMcpPoll() { } // 加载外部MCP列表并渲染 -async function loadExternalMCPs() { +async function loadExternalMCPs(options = {}) { try { // 等待 i18n 就绪,避免快速刷新时翻译函数未初始化导致显示占位符 if (window.i18nReady) await window.i18nReady; const data = await fetchExternalMCPs(); - renderExternalMCPList(data.servers || {}); - renderExternalMCPStats(data.stats || {}); + renderExternalMCPData(data, options.forceRender === true); } catch (error) { console.error('加载外部MCP列表失败:', error); const list = document.getElementById('external-mcp-list'); @@ -3150,8 +3185,7 @@ async function pollExternalMCPToolCount(name, maxAttempts = 10) { await new Promise(r => setTimeout(r, pollIntervalMs)); try { const data = await fetchExternalMCPs(); - renderExternalMCPList(data.servers || {}); - renderExternalMCPStats(data.stats || {}); + renderExternalMCPData(data); if (name != null) { const server = data.servers && data.servers[name]; if (server && server.tool_count > 0) break; @@ -3742,7 +3776,7 @@ document.addEventListener('languagechange', function () { const mcpPage = document.getElementById('page-mcp-management'); if (mcpPage && mcpPage.classList.contains('active')) { if (typeof loadExternalMCPs === 'function') { - loadExternalMCPs().catch(function () { /* ignore */ }); + loadExternalMCPs({ forceRender: true }).catch(function () { /* ignore */ }); } if (typeof updateToolsStats === 'function') { updateToolsStats().catch(function () { /* ignore */ }); diff --git a/web/templates/index.html b/web/templates/index.html index a5c8f707..78e3de1e 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -23,7 +23,7 @@ } })(); - + @@ -1780,29 +1780,81 @@

资产库

+
- - - - - +
+ + + + + + + +
+ +
+ + + +
- - + +
目标服务所属项目上次扫描相关漏洞风险等级状态操作
暂无数据
目标服务所属项目归属上次扫描相关漏洞风险等级状态操作
暂无数据
@@ -1810,6 +1862,48 @@
+ + + + +
+

责任与业务属性

用于资产归属、运营分级与风险排序

+
+ + + + + +
+
@@ -2222,12 +2343,13 @@

0 -
+
目标服务标题/指纹来源最近发现状态操作
+
- +
-

信息收集

+

资产管理

FOFA 配置

+

用于资产发现与导入,仅需配置 API Key。

留空则使用默认地址。
-
- - -
@@ -5385,7 +5504,7 @@
- +
对话名称
项目
@@ -6284,15 +6403,15 @@ - - + + - - + + - + @@ -6302,7 +6421,7 @@ - + @@ -6312,6 +6431,6 @@ - +