mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-25 23:40:09 +02:00
Add files via upload
This commit is contained in:
@@ -19744,6 +19744,158 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.vuln-filter-native-select {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.vulnerability-filter-field--project .vuln-filter-select,
|
||||
.vulnerability-filter-field--status .vuln-filter-select {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vulnerability-filter-field--project .vuln-filter-select {
|
||||
min-width: 132px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.vulnerability-filter-field--status .vuln-filter-select {
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
.vuln-filter-select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.vuln-filter-select-trigger:hover:not(:disabled) {
|
||||
border-color: rgba(59, 130, 246, 0.45);
|
||||
}
|
||||
|
||||
.vuln-filter-select.open .vuln-filter-select-trigger {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.vuln-filter-select.open {
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.vuln-filter-select-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vuln-filter-select-caret {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.vuln-filter-select.open .vuln-filter-select-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.vuln-filter-select-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.vuln-filter-select.open .vuln-filter-select-dropdown {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vuln-filter-select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.vuln-filter-select-option:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.vuln-filter-select-option.is-selected {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vuln-filter-select-check {
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.vuln-filter-select-option.is-selected .vuln-filter-select-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vuln-filter-select-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vuln-filter-select.is-disabled .vuln-filter-select-trigger {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vulnerability-filter-clear-btn[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -20044,6 +20196,167 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
||||
color: #868e96;
|
||||
}
|
||||
|
||||
.vuln-status-picker {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vuln-status-picker.open {
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
.vuln-status-picker-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
max-width: 148px;
|
||||
transition: opacity 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.vuln-status-picker-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vuln-status-picker-caret {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.vuln-status-picker.open .vuln-status-picker-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.vuln-status-picker.open .vuln-status-picker-trigger {
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.12);
|
||||
}
|
||||
|
||||
.vuln-status-picker.status-open .vuln-status-picker-trigger {
|
||||
background: rgba(0, 102, 255, 0.1);
|
||||
color: #0066ff;
|
||||
border-color: rgba(0, 102, 255, 0.22);
|
||||
}
|
||||
|
||||
.vuln-status-picker.status-confirmed .vuln-status-picker-trigger {
|
||||
background: rgba(40, 167, 69, 0.1);
|
||||
color: #28a745;
|
||||
border-color: rgba(40, 167, 69, 0.22);
|
||||
}
|
||||
|
||||
.vuln-status-picker.status-fixed .vuln-status-picker-trigger {
|
||||
background: rgba(108, 117, 125, 0.1);
|
||||
color: #6c757d;
|
||||
border-color: rgba(108, 117, 125, 0.22);
|
||||
}
|
||||
|
||||
.vuln-status-picker.status-false_positive .vuln-status-picker-trigger {
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
color: #dc3545;
|
||||
border-color: rgba(220, 53, 69, 0.22);
|
||||
}
|
||||
|
||||
.vuln-status-picker.status-ignored .vuln-status-picker-trigger {
|
||||
background: rgba(108, 117, 125, 0.12);
|
||||
color: #868e96;
|
||||
border-color: rgba(108, 117, 125, 0.22);
|
||||
}
|
||||
|
||||
.vuln-status-picker-trigger:hover:not(:disabled) {
|
||||
filter: brightness(0.97);
|
||||
}
|
||||
|
||||
.vuln-status-picker.is-disabled .vuln-status-picker-trigger {
|
||||
opacity: 0.65;
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.vuln-status-picker-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
min-width: 136px;
|
||||
z-index: 200;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.vuln-status-picker-menu[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vuln-status-picker-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.vuln-status-picker-option:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.vuln-status-picker-option.is-selected {
|
||||
background: rgba(0, 102, 255, 0.08);
|
||||
color: var(--accent-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vuln-status-picker-check {
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.vuln-status-picker-option.is-selected .vuln-status-picker-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vuln-status-picker-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vulnerability-card--removing {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.vulnerability-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -1896,6 +1896,8 @@
|
||||
"statusFixed": "Fixed",
|
||||
"statusFalsePositive": "False positive",
|
||||
"statusIgnored": "Ignored",
|
||||
"statusChangeLabel": "Change status",
|
||||
"statusUpdateFailed": "Failed to update status",
|
||||
"searchVulnId": "Search vuln ID",
|
||||
"searchKeyword": "Search title, description, type, target…",
|
||||
"searchKeywordShort": "Keyword",
|
||||
@@ -1925,6 +1927,8 @@
|
||||
"detailTarget": "Target",
|
||||
"detailProject": "Project",
|
||||
"projectUnbound": "No project",
|
||||
"allProjects": "All projects",
|
||||
"filterByProject": "Filter by project",
|
||||
"projectBindHint": "Once bound, agents can list this finding under the project scope.",
|
||||
"projectBindFailed": "Failed to update project binding",
|
||||
"projectBindOk": "Project binding updated",
|
||||
|
||||
@@ -1884,6 +1884,8 @@
|
||||
"statusFixed": "已修复",
|
||||
"statusFalsePositive": "误报",
|
||||
"statusIgnored": "已忽略",
|
||||
"statusChangeLabel": "更改状态",
|
||||
"statusUpdateFailed": "更新状态失败",
|
||||
"searchVulnId": "搜索漏洞 ID",
|
||||
"searchKeyword": "搜索标题、描述、类型、目标…",
|
||||
"searchKeywordShort": "关键词",
|
||||
@@ -1913,6 +1915,8 @@
|
||||
"detailTarget": "目标",
|
||||
"detailProject": "所属项目",
|
||||
"projectUnbound": "未绑定项目",
|
||||
"allProjects": "全部项目",
|
||||
"filterByProject": "按项目筛选",
|
||||
"projectBindHint": "绑定后 Agent 可在项目范围内查询到该漏洞",
|
||||
"projectBindFailed": "绑定项目失败",
|
||||
"projectBindOk": "已更新项目绑定",
|
||||
|
||||
+434
-17
@@ -39,6 +39,220 @@ function vulnStatusLabel(code) {
|
||||
return m[code] ? vulnT(m[code]) : code;
|
||||
}
|
||||
|
||||
const VULN_STATUS_CODES = ['open', 'confirmed', 'fixed', 'false_positive', 'ignored'];
|
||||
const VULNERABILITY_REMOVE_ANIM_MS = 200;
|
||||
|
||||
function getVulnerabilityScrollContainer() {
|
||||
const page = document.getElementById('page-vulnerabilities');
|
||||
return page ? page.querySelector('.page-content') : null;
|
||||
}
|
||||
|
||||
function getExpandedVulnerabilityIds() {
|
||||
const ids = [];
|
||||
document.querySelectorAll('#vulnerabilities-list .vulnerability-content').forEach(function (el) {
|
||||
if (el.style.display !== 'none') {
|
||||
const id = (el.id || '').replace(/^content-/, '');
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function restoreExpandedVulnerabilityDetails(expandedIds) {
|
||||
if (!expandedIds || !expandedIds.length) return;
|
||||
expandedIds.forEach(function (id) {
|
||||
const content = document.getElementById('content-' + id);
|
||||
const icon = document.getElementById('expand-icon-' + id);
|
||||
if (!content || content.style.display !== 'none') return;
|
||||
content.style.display = 'block';
|
||||
if (icon) icon.style.transform = 'rotate(90deg)';
|
||||
loadVulnerabilityRelatedFacts(id).catch(function (e) { console.warn(e); });
|
||||
});
|
||||
}
|
||||
|
||||
function buildVulnerabilityStatusPicker(vuln) {
|
||||
const current = vuln.status || 'open';
|
||||
const id = escapeHtml(vuln.id);
|
||||
const label = escapeHtml(vulnT('vulnerabilityPage.statusChangeLabel'));
|
||||
const caretSvg = '<svg class="vuln-status-picker-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
const options = VULN_STATUS_CODES.map(function (code) {
|
||||
const selected = code === current;
|
||||
const selCls = selected ? ' is-selected' : '';
|
||||
const ariaSel = selected ? ' aria-selected="true"' : ' aria-selected="false"';
|
||||
return '<button type="button" class="vuln-status-picker-option' + selCls + '" role="option" data-value="' + code + '"' + ariaSel + '>' +
|
||||
'<span class="vuln-status-picker-check" aria-hidden="true">✓</span>' +
|
||||
'<span class="vuln-status-picker-label">' + escapeHtml(vulnStatusLabel(code)) + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
return '<div class="vuln-status-picker status-' + escapeHtml(current) + '" data-vuln-id="' + id + '" data-prev-status="' + escapeHtml(current) + '">' +
|
||||
'<button type="button" class="vuln-status-picker-trigger" aria-label="' + label + '" aria-haspopup="listbox" aria-expanded="false">' +
|
||||
'<span class="vuln-status-picker-value">' + escapeHtml(vulnStatusLabel(current)) + '</span>' +
|
||||
caretSvg +
|
||||
'</button>' +
|
||||
'<div class="vuln-status-picker-menu" role="listbox" hidden>' + options + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
const VULN_STATUS_PICKER_STATUS_CLASSES = VULN_STATUS_CODES.map(function (code) {
|
||||
return 'status-' + code;
|
||||
});
|
||||
|
||||
function setVulnerabilityStatusPickerDisabled(pickerEl, disabled) {
|
||||
if (!pickerEl) return;
|
||||
pickerEl.classList.toggle('is-disabled', !!disabled);
|
||||
const trigger = pickerEl.querySelector('.vuln-status-picker-trigger');
|
||||
if (trigger) trigger.disabled = !!disabled;
|
||||
}
|
||||
|
||||
function updateVulnerabilityStatusPicker(pickerEl, status) {
|
||||
if (!pickerEl) return;
|
||||
const code = status || 'open';
|
||||
VULN_STATUS_PICKER_STATUS_CLASSES.forEach(function (cls) {
|
||||
pickerEl.classList.remove(cls);
|
||||
});
|
||||
pickerEl.classList.add('status-' + code);
|
||||
pickerEl.dataset.prevStatus = code;
|
||||
const valueEl = pickerEl.querySelector('.vuln-status-picker-value');
|
||||
if (valueEl) valueEl.textContent = vulnStatusLabel(code);
|
||||
pickerEl.querySelectorAll('.vuln-status-picker-option').forEach(function (opt) {
|
||||
const isSel = opt.getAttribute('data-value') === code;
|
||||
opt.classList.toggle('is-selected', isSel);
|
||||
opt.setAttribute('aria-selected', isSel ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
let vulnerabilityStatusPickerDocBound = false;
|
||||
|
||||
function closeAllVulnerabilityStatusPickers() {
|
||||
document.querySelectorAll('.vuln-status-picker.open').forEach(function (picker) {
|
||||
picker.classList.remove('open');
|
||||
const menu = picker.querySelector('.vuln-status-picker-menu');
|
||||
const trigger = picker.querySelector('.vuln-status-picker-trigger');
|
||||
if (menu) menu.hidden = true;
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function initVulnerabilityStatusPickers(root) {
|
||||
if (!vulnerabilityStatusPickerDocBound) {
|
||||
document.addEventListener('click', closeAllVulnerabilityStatusPickers);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeAllVulnerabilityStatusPickers();
|
||||
});
|
||||
vulnerabilityStatusPickerDocBound = true;
|
||||
}
|
||||
|
||||
const scope = root || document.getElementById('vulnerabilities-list');
|
||||
if (!scope) return;
|
||||
|
||||
scope.querySelectorAll('.vuln-status-picker').forEach(function (picker) {
|
||||
if (picker.dataset.bound === '1') return;
|
||||
picker.dataset.bound = '1';
|
||||
|
||||
picker.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
picker.addEventListener('keydown', function (e) { e.stopPropagation(); });
|
||||
|
||||
const trigger = picker.querySelector('.vuln-status-picker-trigger');
|
||||
const menu = picker.querySelector('.vuln-status-picker-menu');
|
||||
if (!trigger || !menu) return;
|
||||
|
||||
trigger.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (picker.classList.contains('is-disabled')) return;
|
||||
const wasOpen = picker.classList.contains('open');
|
||||
closeAllVulnerabilityStatusPickers();
|
||||
if (!wasOpen) {
|
||||
picker.classList.add('open');
|
||||
menu.hidden = false;
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
menu.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
const opt = e.target.closest('.vuln-status-picker-option');
|
||||
if (!opt || picker.classList.contains('is-disabled')) return;
|
||||
const newStatus = opt.getAttribute('data-value');
|
||||
const vulnId = picker.dataset.vulnId;
|
||||
closeAllVulnerabilityStatusPickers();
|
||||
changeVulnerabilityStatus(vulnId, newStatus, picker);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function vulnerabilityStatusMatchesFilter(status) {
|
||||
const filterStatus = (vulnerabilityFilters.status || '').trim();
|
||||
return !filterStatus || filterStatus === status;
|
||||
}
|
||||
|
||||
function removeVulnerabilityCard(vulnId, options) {
|
||||
const opts = options || {};
|
||||
const card = document.getElementById('vulnerability-card-' + vulnId) ||
|
||||
document.querySelector('.vulnerability-card[data-vuln-id="' + vulnId + '"]');
|
||||
if (!card) return;
|
||||
|
||||
const nextCard = card.nextElementSibling;
|
||||
card.classList.add('vulnerability-card--removing');
|
||||
|
||||
setTimeout(function () {
|
||||
card.remove();
|
||||
if (opts.decrementTotal !== false) {
|
||||
vulnerabilityPagination.total = Math.max(0, (vulnerabilityPagination.total || 0) - 1);
|
||||
vulnerabilityPagination.totalPages = Math.max(
|
||||
1,
|
||||
Math.ceil(vulnerabilityPagination.total / vulnerabilityPagination.pageSize)
|
||||
);
|
||||
renderVulnerabilityPagination();
|
||||
}
|
||||
|
||||
const list = document.getElementById('vulnerabilities-list');
|
||||
const remaining = list ? list.querySelectorAll('.vulnerability-card').length : 0;
|
||||
if (remaining === 0) {
|
||||
if (vulnerabilityPagination.currentPage > 1) {
|
||||
vulnerabilityPagination.currentPage--;
|
||||
}
|
||||
loadVulnerabilities();
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.focusNext !== false && nextCard && nextCard.classList.contains('vulnerability-card')) {
|
||||
nextCard.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}, VULNERABILITY_REMOVE_ANIM_MS);
|
||||
}
|
||||
|
||||
async function changeVulnerabilityStatus(vulnId, newStatus, pickerEl) {
|
||||
if (!vulnId || !pickerEl) return;
|
||||
const prevStatus = pickerEl.dataset.prevStatus || newStatus;
|
||||
if (newStatus === prevStatus) return;
|
||||
|
||||
setVulnerabilityStatusPickerDisabled(pickerEl, true);
|
||||
try {
|
||||
const response = await apiFetch('/api/vulnerabilities/' + encodeURIComponent(vulnId), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(function () { return {}; });
|
||||
throw new Error(err.error || vulnT('vulnerabilityPage.statusUpdateFailed'));
|
||||
}
|
||||
|
||||
updateVulnerabilityStatusPicker(pickerEl, newStatus);
|
||||
loadVulnerabilityStats();
|
||||
|
||||
if (!vulnerabilityStatusMatchesFilter(newStatus)) {
|
||||
removeVulnerabilityCard(vulnId, { decrementTotal: true, focusNext: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新漏洞状态失败:', error);
|
||||
updateVulnerabilityStatusPicker(pickerEl, prevStatus);
|
||||
alert(vulnT('vulnerabilityPage.statusUpdateFailed') + ': ' + error.message);
|
||||
} finally {
|
||||
setVulnerabilityStatusPickerDisabled(pickerEl, false);
|
||||
}
|
||||
}
|
||||
|
||||
// 从localStorage读取每页显示数量,默认为20
|
||||
const getVulnerabilityPageSize = () => {
|
||||
const saved = localStorage.getItem('vulnerabilityPageSize');
|
||||
@@ -175,6 +389,7 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
||||
syncVulnerabilityStatCardActiveState();
|
||||
updateVulnerabilityFilterPanelState();
|
||||
renderVulnerabilityFilterChips();
|
||||
syncAllVulnFilterCustomSelects();
|
||||
}
|
||||
|
||||
// 初始化漏洞管理页面
|
||||
@@ -387,6 +602,7 @@ function initVulnerabilityFilterPanel() {
|
||||
|
||||
if (vulnerabilityFilterPanelBound) {
|
||||
updateVulnerabilityFilterPanelState();
|
||||
syncAllVulnFilterCustomSelects();
|
||||
return;
|
||||
}
|
||||
vulnerabilityFilterPanelBound = true;
|
||||
@@ -448,6 +664,146 @@ function initVulnerabilityFilterPanel() {
|
||||
});
|
||||
|
||||
bindVulnerabilityFilterTypeaheads();
|
||||
initVulnerabilityFilterSelects();
|
||||
}
|
||||
|
||||
const VULN_FILTER_CUSTOM_SELECT_IDS = ['vulnerability-project-filter', 'vulnerability-status-filter'];
|
||||
const vulnFilterCustomSelectMap = {};
|
||||
let vulnFilterCustomSelectDocBound = false;
|
||||
|
||||
const VULN_FILTER_SELECT_CARET = '<svg class="vuln-filter-select-caret" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
|
||||
function closeAllVulnFilterCustomSelects() {
|
||||
Object.keys(vulnFilterCustomSelectMap).forEach(function (id) {
|
||||
const reg = vulnFilterCustomSelectMap[id];
|
||||
if (!reg || !reg.wrapper) return;
|
||||
reg.wrapper.classList.remove('open');
|
||||
if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function syncVulnFilterCustomSelect(selectId) {
|
||||
const reg = vulnFilterCustomSelectMap[selectId];
|
||||
if (!reg) return;
|
||||
const select = reg.select;
|
||||
const dropdown = reg.dropdown;
|
||||
const trigger = reg.trigger;
|
||||
const valueSpan = trigger.querySelector('.vuln-filter-select-value');
|
||||
|
||||
dropdown.innerHTML = '';
|
||||
Array.prototype.forEach.call(select.options, function (opt) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'vuln-filter-select-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', opt.value);
|
||||
if (opt.value === select.value) {
|
||||
item.classList.add('is-selected');
|
||||
item.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
item.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
const check = document.createElement('span');
|
||||
check.className = 'vuln-filter-select-check';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
check.textContent = '✓';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'vuln-filter-select-label';
|
||||
label.textContent = opt.textContent;
|
||||
item.appendChild(check);
|
||||
item.appendChild(label);
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
const selectedOpt = select.options[select.selectedIndex];
|
||||
if (valueSpan) {
|
||||
valueSpan.textContent = selectedOpt ? selectedOpt.textContent : '';
|
||||
}
|
||||
trigger.disabled = !!select.disabled;
|
||||
reg.wrapper.classList.toggle('is-disabled', !!select.disabled);
|
||||
}
|
||||
|
||||
function syncAllVulnFilterCustomSelects() {
|
||||
VULN_FILTER_CUSTOM_SELECT_IDS.forEach(syncVulnFilterCustomSelect);
|
||||
}
|
||||
|
||||
function enhanceVulnFilterCustomSelect(selectId) {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
if (select.dataset.vulnCustomSelect === '1') {
|
||||
syncVulnFilterCustomSelect(selectId);
|
||||
return;
|
||||
}
|
||||
select.dataset.vulnCustomSelect = '1';
|
||||
select.classList.add('vuln-filter-native-select');
|
||||
select.tabIndex = -1;
|
||||
select.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'vuln-filter-select';
|
||||
|
||||
const trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'vuln-filter-select-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
const valueSpan = document.createElement('span');
|
||||
valueSpan.className = 'vuln-filter-select-value';
|
||||
trigger.appendChild(valueSpan);
|
||||
trigger.insertAdjacentHTML('beforeend', VULN_FILTER_SELECT_CARET);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'vuln-filter-select-dropdown';
|
||||
dropdown.setAttribute('role', 'listbox');
|
||||
|
||||
const parent = select.parentNode;
|
||||
parent.insertBefore(wrapper, select);
|
||||
wrapper.appendChild(trigger);
|
||||
wrapper.appendChild(dropdown);
|
||||
wrapper.appendChild(select);
|
||||
|
||||
vulnFilterCustomSelectMap[selectId] = { wrapper: wrapper, trigger: trigger, dropdown: dropdown, select: select };
|
||||
|
||||
trigger.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (select.disabled) return;
|
||||
if (typeof closeAllVulnerabilityStatusPickers === 'function') {
|
||||
closeAllVulnerabilityStatusPickers();
|
||||
}
|
||||
const open = wrapper.classList.contains('open');
|
||||
closeAllVulnFilterCustomSelects();
|
||||
if (!open) {
|
||||
wrapper.classList.add('open');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
dropdown.addEventListener('click', function (e) {
|
||||
const opt = e.target.closest('.vuln-filter-select-option');
|
||||
if (!opt) return;
|
||||
e.stopPropagation();
|
||||
const val = opt.getAttribute('data-value');
|
||||
if (val === null) return;
|
||||
if (select.value !== val) {
|
||||
select.value = val;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
wrapper.classList.remove('open');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
syncVulnFilterCustomSelect(selectId);
|
||||
});
|
||||
}
|
||||
|
||||
function initVulnerabilityFilterSelects() {
|
||||
if (!vulnFilterCustomSelectDocBound) {
|
||||
document.addEventListener('click', closeAllVulnFilterCustomSelects);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeAllVulnFilterCustomSelects();
|
||||
});
|
||||
vulnFilterCustomSelectDocBound = true;
|
||||
}
|
||||
VULN_FILTER_CUSTOM_SELECT_IDS.forEach(enhanceVulnFilterCustomSelect);
|
||||
syncAllVulnFilterCustomSelects();
|
||||
}
|
||||
|
||||
function countVulnerabilityAdvancedFiltersActive() {
|
||||
@@ -559,6 +915,9 @@ function removeVulnerabilityFilterByKey(key) {
|
||||
if (Object.prototype.hasOwnProperty.call(vulnerabilityFilters, key)) {
|
||||
vulnerabilityFilters[key] = '';
|
||||
}
|
||||
if (key === 'project_id' || key === 'status') {
|
||||
syncAllVulnFilterCustomSelects();
|
||||
}
|
||||
applyVulnerabilityFilters();
|
||||
}
|
||||
|
||||
@@ -779,9 +1138,22 @@ function updateVulnerabilityStats(stats) {
|
||||
}
|
||||
|
||||
// 加载漏洞列表
|
||||
async function loadVulnerabilities(page = null) {
|
||||
async function loadVulnerabilities(page = null, options = {}) {
|
||||
const opts = options && typeof options === 'object' ? options : {};
|
||||
const preserveScroll = !!opts.preserveScroll;
|
||||
const silent = !!opts.silent;
|
||||
let expandedIds = opts.expandedIds;
|
||||
|
||||
const scrollEl = preserveScroll ? getVulnerabilityScrollContainer() : null;
|
||||
const scrollTop = scrollEl ? scrollEl.scrollTop : 0;
|
||||
if (expandedIds === undefined && preserveScroll) {
|
||||
expandedIds = getExpandedVulnerabilityIds();
|
||||
}
|
||||
|
||||
const listContainer = document.getElementById('vulnerabilities-list');
|
||||
listContainer.innerHTML = `<div class="loading-spinner">${escapeHtml(vulnT('vulnerabilityPage.loading'))}</div>`;
|
||||
if (!silent) {
|
||||
listContainer.innerHTML = `<div class="loading-spinner">${escapeHtml(vulnT('vulnerabilityPage.loading'))}</div>`;
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查apiFetch是否可用
|
||||
@@ -830,8 +1202,14 @@ async function loadVulnerabilities(page = null) {
|
||||
console.error('未知的响应格式:', data);
|
||||
}
|
||||
|
||||
renderVulnerabilities(vulnerabilities);
|
||||
renderVulnerabilities(vulnerabilities, { expandedIds: expandedIds || [] });
|
||||
renderVulnerabilityPagination();
|
||||
|
||||
if (preserveScroll && scrollEl) {
|
||||
requestAnimationFrame(function () {
|
||||
scrollEl.scrollTop = scrollTop;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载漏洞列表失败:', error);
|
||||
listContainer.innerHTML = `<div class="error-message">${escapeHtml(vulnT('vulnerabilityPage.loadListFailed'))}: ${escapeHtml(error.message)}</div>`;
|
||||
@@ -839,7 +1217,8 @@ async function loadVulnerabilities(page = null) {
|
||||
}
|
||||
|
||||
// 渲染漏洞列表
|
||||
function renderVulnerabilities(vulnerabilities) {
|
||||
function renderVulnerabilities(vulnerabilities, renderOptions) {
|
||||
const opts = renderOptions && typeof renderOptions === 'object' ? renderOptions : {};
|
||||
const listContainer = document.getElementById('vulnerabilities-list');
|
||||
|
||||
// 处理空值情况(使用 data-i18n 以便语言切换时自动更新)
|
||||
@@ -862,7 +1241,6 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
const html = vulnerabilities.map(vuln => {
|
||||
const severityClass = `severity-${vuln.severity}`;
|
||||
const severityText = vulnSeverityLabel(vuln.severity);
|
||||
const statusText = vulnStatusLabel(vuln.status);
|
||||
const createdDate = new Date(vuln.created_at).toLocaleString(vulnDateLocale());
|
||||
const projectLabel = vuln.project_id
|
||||
? escapeHtml(typeof getProjectName === 'function' ? getProjectName(vuln.project_id) : vuln.project_id)
|
||||
@@ -875,7 +1253,7 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
const deleteTitle = escapeHtml(vulnT('common.delete'));
|
||||
|
||||
return `
|
||||
<div class="vulnerability-card ${severityClass}">
|
||||
<div class="vulnerability-card ${severityClass}" id="vulnerability-card-${vuln.id}" data-vuln-id="${escapeHtml(vuln.id)}">
|
||||
<div class="vulnerability-header" onclick="toggleVulnerabilityDetails('${vuln.id}')" style="cursor: pointer;">
|
||||
<div class="vulnerability-title-section">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
@@ -886,7 +1264,7 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
</div>
|
||||
<div class="vulnerability-meta">
|
||||
<span class="severity-badge ${severityClass}">${severityText}</span>
|
||||
<span class="status-badge status-${vuln.status}">${statusText}</span>
|
||||
${buildVulnerabilityStatusPicker(vuln)}
|
||||
${projectBadge}
|
||||
<span class="vulnerability-date">${createdDate}</span>
|
||||
</div>
|
||||
@@ -935,10 +1313,13 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
}).join('');
|
||||
|
||||
listContainer.innerHTML = html;
|
||||
initVulnerabilityStatusPickers(listContainer);
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(listContainer);
|
||||
}
|
||||
|
||||
restoreExpandedVulnerabilityDetails(opts.expandedIds);
|
||||
|
||||
// 如果通过漏洞ID筛选且只返回一条记录,自动展开详情(提升“点击查看”的用户体验)
|
||||
if (vulnerabilities.length === 1 && vulnerabilityFilters.id && vulnerabilityFilters.id === vulnerabilities[0].id) {
|
||||
setTimeout(() => {
|
||||
@@ -1191,11 +1572,27 @@ async function saveVulnerability() {
|
||||
throw new Error(error.error || vulnT('vulnerabilityPage.saveFailed'));
|
||||
}
|
||||
|
||||
const updated = await response.json();
|
||||
const editedId = currentVulnerabilityId;
|
||||
const isEdit = !!editedId;
|
||||
const expandedIds = isEdit ? getExpandedVulnerabilityIds() : [];
|
||||
|
||||
closeVulnerabilityModal();
|
||||
loadVulnerabilityStats();
|
||||
// 保存/更新后,重置到第一页
|
||||
vulnerabilityPagination.currentPage = 1;
|
||||
loadVulnerabilities();
|
||||
|
||||
if (!isEdit) {
|
||||
vulnerabilityPagination.currentPage = 1;
|
||||
loadVulnerabilities();
|
||||
return;
|
||||
}
|
||||
|
||||
const newStatus = (updated && updated.status) || data.status;
|
||||
if (!vulnerabilityStatusMatchesFilter(newStatus)) {
|
||||
removeVulnerabilityCard(editedId, { decrementTotal: true, focusNext: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await loadVulnerabilities(null, { preserveScroll: true, silent: true, expandedIds: expandedIds });
|
||||
} catch (error) {
|
||||
console.error('保存漏洞失败:', error);
|
||||
alert(vulnT('vulnerabilityPage.saveFailed') + ': ' + error.message);
|
||||
@@ -1216,14 +1613,20 @@ async function deleteVulnerability(id) {
|
||||
if (!response.ok) throw new Error(vulnT('vulnerabilityPage.deleteFailed'));
|
||||
|
||||
loadVulnerabilityStats();
|
||||
// 删除后,如果当前页没有数据了,回到上一页
|
||||
const card = document.getElementById('vulnerability-card-' + id) ||
|
||||
document.querySelector('.vulnerability-card[data-vuln-id="' + id + '"]');
|
||||
if (card) {
|
||||
removeVulnerabilityCard(id, { decrementTotal: true, focusNext: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (vulnerabilityPagination.currentPage > 1 && vulnerabilityPagination.total > 0) {
|
||||
const itemsOnCurrentPage = vulnerabilityPagination.total - (vulnerabilityPagination.currentPage - 1) * vulnerabilityPagination.pageSize;
|
||||
if (itemsOnCurrentPage <= 1) {
|
||||
vulnerabilityPagination.currentPage--;
|
||||
}
|
||||
}
|
||||
loadVulnerabilities();
|
||||
await loadVulnerabilities(null, { preserveScroll: true });
|
||||
} catch (error) {
|
||||
console.error('删除漏洞失败:', error);
|
||||
alert(vulnT('vulnerabilityPage.deleteFailed') + ': ' + error.message);
|
||||
@@ -1263,6 +1666,7 @@ function clearVulnerabilityFilters() {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
syncAllVulnFilterCustomSelects();
|
||||
|
||||
vulnerabilityFilters = {
|
||||
q: '',
|
||||
@@ -1685,10 +2089,16 @@ window.onclick = function(event) {
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('languagechange', function () {
|
||||
document.addEventListener('languagechange', async function () {
|
||||
const page = document.getElementById('page-vulnerabilities');
|
||||
if (page && page.classList.contains('active')) {
|
||||
const panel = document.getElementById('vulnerability-filter-panel');
|
||||
if (panel && typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(panel);
|
||||
}
|
||||
renderVulnerabilityFilterChips();
|
||||
await refreshVulnerabilityProjectFilter();
|
||||
syncAllVulnFilterCustomSelects();
|
||||
loadVulnerabilities();
|
||||
}
|
||||
});
|
||||
@@ -1709,11 +2119,15 @@ async function bindVulnerabilityProject(vulnId, projectId, silent) {
|
||||
alert(vulnT('vulnerabilityPage.projectBindOk'));
|
||||
}
|
||||
loadVulnerabilityStats();
|
||||
loadVulnerabilities();
|
||||
const expandedIds = getExpandedVulnerabilityIds();
|
||||
if (!expandedIds.includes(vulnId)) {
|
||||
expandedIds.push(vulnId);
|
||||
}
|
||||
await loadVulnerabilities(null, { preserveScroll: true, silent: true, expandedIds: expandedIds });
|
||||
} catch (error) {
|
||||
console.error('绑定项目失败:', error);
|
||||
alert(vulnT('vulnerabilityPage.projectBindFailed') + ': ' + error.message);
|
||||
loadVulnerabilities();
|
||||
await loadVulnerabilities(null, { preserveScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1738,15 +2152,16 @@ async function refreshVulnerabilityProjectFilter() {
|
||||
list.forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; });
|
||||
}
|
||||
const cur = vulnerabilityFilters.project_id || sel.value || '';
|
||||
let html = '<option value="">全部项目</option>';
|
||||
let html = '<option value="">' + escapeHtml(vulnT('vulnerabilityPage.allProjects')) + '</option>';
|
||||
(list || []).forEach((p) => {
|
||||
if (!p.id) return;
|
||||
const selected = p.id === cur ? ' selected' : '';
|
||||
const arch = p.status === 'archived' ? ' [归档]' : '';
|
||||
const arch = p.status === 'archived' ? ' [' + vulnT('projects.archived') + ']' : '';
|
||||
html += `<option value="${escapeHtml(p.id)}"${selected}>${escapeHtml(p.name || p.id)}${arch}</option>`;
|
||||
});
|
||||
sel.innerHTML = html;
|
||||
if (cur) sel.value = cur;
|
||||
syncVulnFilterCustomSelect('vulnerability-project-filter');
|
||||
const modalSel = document.getElementById('vulnerability-project-id');
|
||||
if (modalSel && isAppModalOpen('vulnerability-modal')) {
|
||||
const modalCur = modalSel.value || '';
|
||||
@@ -1762,6 +2177,7 @@ function setVulnerabilityProjectFilter(projectId) {
|
||||
vulnerabilityFilters.project_id = projectId || '';
|
||||
const sel = document.getElementById('vulnerability-project-filter');
|
||||
if (sel) sel.value = projectId || '';
|
||||
syncVulnFilterCustomSelect('vulnerability-project-filter');
|
||||
applyVulnerabilityFilters();
|
||||
}
|
||||
|
||||
@@ -1777,4 +2193,5 @@ window.setVulnerabilityProjectFilter = setVulnerabilityProjectFilter;
|
||||
window.setVulnerabilityIdFilter = setVulnerabilityIdFilter;
|
||||
window.bindVulnerabilityProject = bindVulnerabilityProject;
|
||||
window.buildVulnerabilityProjectOptionsHtml = buildVulnerabilityProjectOptionsHtml;
|
||||
window.changeVulnerabilityStatus = changeVulnerabilityStatus;
|
||||
|
||||
|
||||
@@ -1929,14 +1929,14 @@
|
||||
data-i18n="vulnerabilityPage.searchKeyword" data-i18n-attr="placeholder" placeholder="搜索标题、描述、类型、目标…" />
|
||||
</label>
|
||||
<label class="vulnerability-filter-field vulnerability-filter-field--project">
|
||||
<span class="sr-only">项目</span>
|
||||
<select id="vulnerability-project-filter" title="按项目筛选" onchange="scheduleVulnerabilityFilterApply(true)">
|
||||
<option value="">全部项目</option>
|
||||
<span class="sr-only" data-i18n="vulnerabilityPage.detailProject">项目</span>
|
||||
<select id="vulnerability-project-filter" data-i18n="vulnerabilityPage.filterByProject" data-i18n-attr="title" title="按项目筛选" onchange="scheduleVulnerabilityFilterApply(true)">
|
||||
<option value="" data-i18n="vulnerabilityPage.allProjects">全部项目</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="vulnerability-filter-field vulnerability-filter-field--status">
|
||||
<span class="sr-only" data-i18n="vulnerabilityPage.status">状态</span>
|
||||
<select id="vulnerability-status-filter" data-i18n-attr="title" title="状态">
|
||||
<select id="vulnerability-status-filter" data-i18n="vulnerabilityPage.status" data-i18n-attr="title" title="状态">
|
||||
<option value="" data-i18n="knowledgePage.all">全部状态</option>
|
||||
<option value="open" data-i18n="vulnerabilityPage.statusOpen">待处理</option>
|
||||
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
|
||||
|
||||
Reference in New Issue
Block a user