mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-19 02:17:38 +02:00
Add files via upload
This commit is contained in:
+380
-67
@@ -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)); } }
|
||||
|
||||
+114
-8
@@ -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",
|
||||
|
||||
+115
-9
@@ -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": "最大迭代次数",
|
||||
|
||||
@@ -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'];
|
||||
|
||||
+722
-15
@@ -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
|
||||
? `<span class="asset-import-result asset-import-result--error">${escapeHtml(row.error)}</span>`
|
||||
: `<span class="asset-import-result asset-import-result--ok">${escapeHtml(assetT('assets.validationPassed', '通过'))}</span>`;
|
||||
return `<tr class="${row.error ? 'has-error' : ''}"><td>${row.rowNumber}</td><td title="${escapeHtml(target)}">${escapeHtml(target)}</td><td>${escapeHtml(row.projectName || row.values.project || '-')}</td><td>${escapeHtml(asset.status || row.values.status || '-')}</td><td>${result}</td></tr>`;
|
||||
}).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 = `<option value="">${escapeHtml(assetT('assets.savedViews', '保存的筛选视图'))}</option>` + views.map(view => `<option value="${escapeHtml(view.name)}">${escapeHtml(view.name)}</option>`).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 = '<tr><td colspan="9" class="muted">' + escapeHtml(assetT('common.loading', '加载中...')) + '</td></tr>';
|
||||
if (body) body.innerHTML = '<tr><td colspan="10" class="muted">' + escapeHtml(assetT('common.loading', '加载中...')) + '</td></tr>';
|
||||
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 = '<tr><td colspan="9" class="muted">' + escapeHtml(assetT('assets.loadFailed', '加载资产失败')) + '</td></tr>';
|
||||
if (body) body.innerHTML = '<tr><td colspan="10" class="muted">' + escapeHtml(assetT('assets.loadFailed', '加载资产失败')) + '</td></tr>';
|
||||
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 `<span class="asset-ownership-empty">${escapeHtml(assetT('assets.unassigned', '未分配'))}</span>`;
|
||||
}
|
||||
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 `<div class="asset-ownership" title="${escapeHtml(title)}">
|
||||
<span class="asset-ownership__primary">${escapeHtml(primary)}</span>
|
||||
${owner && department ? `<span class="asset-ownership__secondary">${escapeHtml(department)}</span>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = '<tr><td colspan="9" class="muted">' + escapeHtml(assetT('common.noData', '暂无数据')) + '</td></tr>';
|
||||
body.innerHTML = '<tr><td colspan="10" class="muted">' + escapeHtml(assetT('common.noData', '暂无数据')) + '</td></tr>';
|
||||
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 `<tr>
|
||||
<td class="asset-check-cell"><input type="checkbox" ${assetPageState.selected.has(asset.id) ? 'checked' : ''} onchange="toggleAssetSelection(${index},this.checked)" aria-label="${escapeHtml(assetT('assets.selectAsset', '选择资产'))}"></td>
|
||||
<td class="asset-check-cell"><input type="checkbox" class="theme-checkbox" ${assetPageState.selected.has(asset.id) ? 'checked' : ''} onchange="toggleAssetSelection(${index},this.checked)" aria-label="${escapeHtml(assetT('assets.selectAsset', '选择资产'))}"></td>
|
||||
<td><button class="asset-target-link" title="${escapeHtml(targetHint)}" onclick="openAssetDetail(${index})">${escapeHtml(assetTargetLabel(asset))}</button></td>
|
||||
<td><span class="asset-service" title="${escapeHtml(service)}">${escapeHtml(service)}</span></td>
|
||||
<td>${asset.project_name ? `<span class="asset-project-badge">${escapeHtml(asset.project_name)}</span>` : '<span class="muted">-</span>'}</td>
|
||||
<td>${assetOwnershipMarkup(asset)}</td>
|
||||
<td>${escapeHtml(lastScan)}</td><td>${vulnerabilityCount > 0 ? `<button class="asset-vulnerability-link" onclick="openAssetVulnerabilities(${index})">${vulnerabilityCount}</button>` : '<span class="muted">0</span>'}</td>
|
||||
<td><span class="asset-risk asset-risk--${risk.level}">${escapeHtml(risk.label)}</span></td>
|
||||
<td><span class="asset-status asset-status--${escapeHtml(asset.status || 'active')}">${escapeHtml(statusLabel)}</span></td>
|
||||
@@ -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 ? `<code>${escapeHtml(asset.source_query)}</code>` : ''),
|
||||
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));
|
||||
|
||||
+15
-7
@@ -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);
|
||||
|
||||
|
||||
@@ -506,7 +506,7 @@ function renderFofaResults(payload) {
|
||||
|
||||
// 表头(左:勾选列;右:操作列固定)
|
||||
const headerCells = [
|
||||
'<th class="info-collect-col-select"><input type="checkbox" id="fofa-select-all" title="' + escapeHtml(_t('infoCollect.selectAll')) + '"/></th>',
|
||||
'<th class="info-collect-col-select"><input type="checkbox" id="fofa-select-all" class="theme-checkbox" title="' + escapeHtml(_t('infoCollect.selectAll')) + '"/></th>',
|
||||
...visibleFields.map(f => `<th>${escapeHtml(String(f))}</th>`),
|
||||
'<th class="info-collect-col-actions">' + escapeHtml(_t('infoCollect.actions')) + '</th>'
|
||||
].join('');
|
||||
@@ -525,7 +525,7 @@ function renderFofaResults(payload) {
|
||||
const encoded = encodeURIComponent(JSON.stringify(safeRow));
|
||||
const encodedTarget = encodeURIComponent(target || '');
|
||||
|
||||
const selectHtml = '<td class="info-collect-col-select"><input class="fofa-row-select" type="checkbox" data-index="' + idx + '" title="' + escapeHtml(_t('infoCollect.selectRow')) + '"/></td>';
|
||||
const selectHtml = '<td class="info-collect-col-select"><input class="fofa-row-select theme-checkbox" type="checkbox" data-index="' + idx + '" title="' + escapeHtml(_t('infoCollect.selectRow')) + '"/></td>';
|
||||
|
||||
const cellsHtml = visibleFields.map(f => {
|
||||
const val = safeRow[f];
|
||||
|
||||
+257
-79
@@ -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 = '<div class="progress-timeline-empty">' + ((typeof window.t === 'function') ? window.t('common.loading') : '加载中…') + '</div>';
|
||||
}
|
||||
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: `
|
||||
<tr data-execution-id="${executionId}">
|
||||
<td>
|
||||
<input type="checkbox" class="monitor-execution-checkbox" value="${executionId}" ${isSelected ? 'checked' : ''} onchange="toggleExecutionSelection('${jsExecId}', this.checked)" />
|
||||
<input type="checkbox" class="monitor-execution-checkbox theme-checkbox" value="${executionId}" ${isSelected ? 'checked' : ''} onchange="toggleExecutionSelection('${jsExecId}', this.checked)" />
|
||||
</td>
|
||||
<td>${toolName}</td>
|
||||
<td><span class="${statusClass}">${escapeHtml(statusLabel)}</span></td>
|
||||
@@ -6269,7 +6447,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||
const headerHtml = `
|
||||
<tr>
|
||||
<th style="width: 40px;">
|
||||
<input type="checkbox" id="monitor-select-all" onchange="toggleSelectAll(this)" />
|
||||
<input type="checkbox" id="monitor-select-all" class="theme-checkbox" onchange="toggleSelectAll(this)" />
|
||||
</th>
|
||||
<th>${escapeHtml(colTool)}</th>
|
||||
<th>${escapeHtml(colStatus)}</th>
|
||||
|
||||
@@ -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 = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tpFmt('common.loading', '加载中...'))}</td></tr>`;
|
||||
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 = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tpFmt('common.loadFailed', '加载失败'))}</td></tr>`;
|
||||
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 = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tpFmt('projects.noBoundAssets', '暂无绑定到此项目的资产'))}</td></tr>`;
|
||||
renderProjectAssetsPagination();
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = currentProjectAssets.map((asset, index) => {
|
||||
@@ -1068,6 +1099,47 @@ async function loadProjectAssets() {
|
||||
<td class="col-actions"><div class="projects-table-actions"><button type="button" class="projects-action-btn projects-action-btn--view" onclick="openProjectAssetDetail(${index})">${escapeHtml(tpFmt('common.view', '查看'))}</button></div></td>
|
||||
</tr>`;
|
||||
}).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 = `<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
<span>${escapeHtml(tpFmt('projects.paginationShow', `显示 ${start}-${end} / 共 ${total}`, { start, end, total }))}</span>
|
||||
<label class="pagination-page-size">${escapeHtml(tpFmt('projects.paginationPerPage', '每页显示'))}
|
||||
<select id="project-assets-page-size" onchange="changeProjectAssetsPageSize(this.value)">
|
||||
${[10, 20, 50, 100].map(size => `<option value="${size}" ${size === pageSize ? 'selected' : ''}>${size}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
<button type="button" class="btn-secondary" onclick="loadProjectAssets(1)" ${atFirst ? 'disabled' : ''}>${escapeHtml(tpFmt('skillsPage.firstPage', '首页'))}</button>
|
||||
<button type="button" class="btn-secondary" onclick="loadProjectAssets(${Math.max(1, page - 1)})" ${atFirst ? 'disabled' : ''}>${escapeHtml(tpFmt('projects.paginationPrev', '上一页'))}</button>
|
||||
<span class="pagination-page">${escapeHtml(tpFmt('skillsPage.pageOf', `第 ${page} / ${totalPages} 页`, { current: page, total: totalPages }))}</span>
|
||||
<button type="button" class="btn-secondary" onclick="loadProjectAssets(${Math.min(totalPages, page + 1)})" ${atLast ? 'disabled' : ''}>${escapeHtml(tpFmt('projects.paginationNext', '下一页'))}</button>
|
||||
<button type="button" class="btn-secondary" onclick="loadProjectAssets(${totalPages})" ${atLast ? 'disabled' : ''}>${escapeHtml(tpFmt('skillsPage.lastPage', '尾页'))}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
+26
-16
@@ -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;
|
||||
|
||||
+54
-20
@@ -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 = '<div class="tools-list-items"><div class="loading" style="padding: 20px; text-align: center; color: var(--text-muted);">⏳ ' + (typeof window.t === 'function' ? window.t('mcp.loadingTools') : '正在加载工具列表...') + '</div></div>';
|
||||
toolsList.setAttribute('aria-busy', 'true');
|
||||
if (!toolsList.querySelector('.tool-item')) {
|
||||
toolsList.innerHTML = '<div class="tools-list-items"><div class="loading" style="padding: 20px; text-align: center; color: var(--text-muted);">⏳ ' + (typeof window.t === 'function' ? window.t('mcp.loadingTools') : '正在加载工具列表...') + '</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = `<div class="error" style="padding: 20px; text-align: center;">${errorMsg}</div>`;
|
||||
}
|
||||
} 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 = `
|
||||
<input type="checkbox" id="${checkboxId}" ${toolState.enabled ? 'checked' : ''} ${toolState.is_external || tool.is_external ? 'data-external="true"' : ''} onchange="handleToolCheckboxChange('${escapeHtml(toolKey)}', this.checked)" />
|
||||
<input type="checkbox" class="theme-checkbox" id="${checkboxId}" ${toolState.enabled ? 'checked' : ''} ${toolState.is_external || tool.is_external ? 'data-external="true"' : ''} onchange="handleToolCheckboxChange('${escapeHtml(toolKey)}', this.checked)" />
|
||||
<div class="tool-item-info">
|
||||
<div class="tool-item-name">
|
||||
${escapeHtml(tool.name)}
|
||||
${externalBadge}
|
||||
<label class="tool-resident-toggle" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleHint') : '始终常驻在 Tool Search 可见列表'}" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" ${alwaysVisibleChecked ? 'checked' : ''} ${alwaysVisibleLocked ? 'disabled' : ''} onchange="handleToolAlwaysVisibleChange('${escapeHtml(toolKey)}', this.checked)" />
|
||||
<input type="checkbox" class="theme-checkbox" ${alwaysVisibleChecked ? 'checked' : ''} ${alwaysVisibleLocked ? 'disabled' : ''} onchange="handleToolAlwaysVisibleChange('${escapeHtml(toolKey)}', this.checked)" />
|
||||
<span>${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleLabel') : '常驻'}</span>
|
||||
</label>
|
||||
${alwaysVisibleLocked ? `<span class="external-tool-badge" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinHint') : '后端内置工具默认常驻,不可关闭'}">${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinLabel') : '内置默认'}</span>` : ''}
|
||||
@@ -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 */ });
|
||||
|
||||
Reference in New Issue
Block a user