diff --git a/web/static/css/style.css b/web/static/css/style.css
index bb34fdf0..a76b8d1f 100644
--- a/web/static/css/style.css
+++ b/web/static/css/style.css
@@ -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);
diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json
index eae3800d..c5797641 100644
--- a/web/static/i18n/en-US.json
+++ b/web/static/i18n/en-US.json
@@ -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",
diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json
index 0bbbec98..a4a8bb15 100644
--- a/web/static/i18n/zh-CN.json
+++ b/web/static/i18n/zh-CN.json
@@ -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": "已更新项目绑定",
diff --git a/web/static/js/vulnerability.js b/web/static/js/vulnerability.js
index 06b37ba7..45470539 100644
--- a/web/static/js/vulnerability.js
+++ b/web/static/js/vulnerability.js
@@ -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 = '';
+ 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 '';
+ }).join('');
+ return '
' +
+ '' +
+ '' +
+ '
';
+}
+
+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 = '';
+
+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 = `${escapeHtml(vulnT('vulnerabilityPage.loading'))}
`;
+ if (!silent) {
+ listContainer.innerHTML = `${escapeHtml(vulnT('vulnerabilityPage.loading'))}
`;
+ }
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 = `${escapeHtml(vulnT('vulnerabilityPage.loadListFailed'))}: ${escapeHtml(error.message)}
`;
@@ -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 `
-