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 ` -
+
@@ -886,7 +1264,7 @@ function renderVulnerabilities(vulnerabilities) {
${severityText} - ${statusText} + ${buildVulnerabilityStatusPicker(vuln)} ${projectBadge} ${createdDate}
@@ -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 = ''; + let html = ''; (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 += ``; }); 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; diff --git a/web/templates/index.html b/web/templates/index.html index 0a8ee7cf..90fdb4ae 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1929,14 +1929,14 @@ data-i18n="vulnerabilityPage.searchKeyword" data-i18n-attr="placeholder" placeholder="搜索标题、描述、类型、目标…" />