Add files via upload

This commit is contained in:
公明
2026-06-26 01:22:30 +08:00
committed by GitHub
parent 7e4a8db7af
commit fb3b4dd6e5
5 changed files with 759 additions and 21 deletions
+313
View File
@@ -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);
+4
View File
@@ -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",
+4
View File
@@ -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
View File
@@ -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;
+4 -4
View File
@@ -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>