From 3e41a47abf6b1bcaf3910b399ed51fb3d017e622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:05:02 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 197 ++++++++++++++++++++ web/static/i18n/en-US.json | 22 ++- web/static/i18n/zh-CN.json | 22 ++- web/static/js/hitl.js | 58 ++++-- web/static/js/monitor.js | 26 ++- web/static/js/notifications.js | 321 +++++++++++++++++++++++++++++++++ web/static/js/vulnerability.js | 19 +- web/templates/index.html | 19 ++ 8 files changed, 662 insertions(+), 22 deletions(-) create mode 100644 web/static/js/notifications.js diff --git a/web/static/css/style.css b/web/static/css/style.css index 0b525465..7fda6e54 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -805,6 +805,185 @@ header { position: relative; } +.notification-menu-container { + position: relative; +} + +.notification-btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s ease; + padding: 0; +} + +.notification-btn:hover, +.notification-btn.active { + background: var(--bg-tertiary); + border-color: var(--accent-color); + color: var(--accent-color); +} + +.notification-btn svg { + width: 20px; + height: 20px; +} + +.notification-badge { + position: absolute; + top: 0; + right: 0; + transform: translate(32%, -32%); + min-width: 18px; + height: 18px; + border-radius: 9999px; + padding: 0 4px; + background: #ef4444; + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + font-size: 0.6875rem; + font-weight: 500; + line-height: 1; + text-align: center; + font-variant-numeric: tabular-nums; + border: 2px solid #fff; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18); + pointer-events: none; +} + +.notification-badge-text { + display: inline-block; + transform: translateY(1.5px); +} + +.notification-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 340px; + max-height: 420px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 10px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16); + z-index: 1000; + overflow: hidden; +} + +.notification-dropdown-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); + font-size: 0.875rem; + font-weight: 600; +} + +.notification-mark-read-btn { + border: none; + background: transparent; + color: var(--accent-color); + cursor: pointer; + font-size: 0.75rem; + padding: 0; +} + +.notification-list { + max-height: 360px; + overflow-y: auto; + padding: 6px 0; +} + +.notification-item { + display: block; + padding: 10px 12px; + border-left: 2px solid transparent; +} + +.notification-item + .notification-item { + border-top: 1px solid var(--border-color); +} + +.notification-item.notification-level-p0 { + border-left-color: #ef4444; +} + +.notification-item.notification-level-p1 { + border-left-color: #f59e0b; +} + +.notification-item.notification-level-p2 { + border-left-color: #3b82f6; +} + +.notification-item-title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 2px; +} + +.notification-item-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.notification-item-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.notification-item-action-btn { + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-secondary); + border-radius: 6px; + font-size: 0.6875rem; + line-height: 1; + padding: 4px 6px; + cursor: pointer; +} + +.notification-item-action-btn:hover { + border-color: var(--accent-color); + color: var(--accent-color); +} + +.notification-item-desc { + font-size: 0.75rem; + color: var(--text-secondary); + line-height: 1.35; +} + +.notification-item-time { + margin-top: 4px; + font-size: 0.6875rem; + color: var(--text-muted); +} + +.notification-empty { + padding: 16px 12px; + color: var(--text-muted); + text-align: center; + font-size: 0.8125rem; +} + .user-avatar-btn { display: inline-flex; align-items: center; @@ -827,6 +1006,8 @@ header { } .user-avatar-btn svg { + width: 22px; + height: 22px; stroke: currentColor; } @@ -3607,6 +3788,17 @@ header { box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.03); } +.active-task-item-clickable { + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.12s ease; +} + +.active-task-item-clickable:hover { + border-color: rgba(0, 102, 255, 0.45); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.03), 0 2px 8px rgba(0, 102, 255, 0.12); + transform: translateY(-1px); +} + .active-task-info { display: flex; align-items: center; @@ -13425,6 +13617,11 @@ header { min-width: 150px; } +/* Keep action buttons visually aligned in vulnerability filters */ +.vulnerability-filters .btn-primary { + border: 1px solid transparent; +} + .vulnerabilities-list { display: flex; flex-direction: column; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 905024fa..54a765f4 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -33,6 +33,13 @@ "version": "Current version", "toggleSidebar": "Collapse/expand sidebar" }, + "notifications": { + "title": "Notifications", + "empty": "No new events", + "markAllRead": "Mark all read", + "markSingleRead": "Read", + "itemDefaultTitle": "Notification" + }, "login": { "title": "Sign in to CyberStrikeAI", "subtitle": "Enter the access password from config", @@ -239,7 +246,20 @@ }, "hitl": { "pageTitle": "HITL approvals", - "pendingTitle": "Pending approvals" + "pendingTitle": "Pending approvals", + "loading": "Loading...", + "emptyState": "No pending approvals", + "dismiss": "Dismiss", + "conversationLabel": "Conversation:", + "reviewEditHelp": "Review & edit mode: provide a JSON object to override tool arguments. Example: {\"command\":\"ls -la\"}", + "approvalHelp": "Approval mode: only approve/reject, argument editing is disabled.", + "commentHelp": "Comment (optional): briefly note the approval reason.", + "commentPlaceholder": "e.g. allow read-only command", + "reject": "Reject", + "approve": "Approve", + "loadFailed": "Failed to load", + "invalidJson": "Invalid JSON arguments", + "submitFailedPrefix": "Submit failed:" }, "progress": { "callingAI": "Calling AI model...", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 905c5c70..019bfcc9 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -33,6 +33,13 @@ "version": "当前版本", "toggleSidebar": "折叠/展开侧边栏" }, + "notifications": { + "title": "事件通知", + "empty": "暂无新事件", + "markAllRead": "标记已读", + "markSingleRead": "已读", + "itemDefaultTitle": "通知" + }, "login": { "title": "登录 CyberStrikeAI", "subtitle": "请输入配置中的访问密码", @@ -239,7 +246,20 @@ }, "hitl": { "pageTitle": "人机协同审批", - "pendingTitle": "待处理审批" + "pendingTitle": "待处理审批", + "loading": "加载中...", + "emptyState": "暂无待审批项", + "dismiss": "忽略", + "conversationLabel": "会话:", + "reviewEditHelp": "审查编辑模式:可填写 JSON 对象覆盖参数。示例:{\"command\":\"ls -la\"}", + "approvalHelp": "审批模式:仅通过/拒绝,不支持改参。", + "commentHelp": "备注(可选):建议写审批依据。", + "commentPlaceholder": "例如:允许只读命令", + "reject": "拒绝", + "approve": "通过", + "loadFailed": "加载失败", + "invalidJson": "JSON 参数格式错误", + "submitFailedPrefix": "提交失败:" }, "progress": { "callingAI": "正在调用AI模型...", diff --git a/web/static/js/hitl.js b/web/static/js/hitl.js index 5334b41b..142bdbac 100644 --- a/web/static/js/hitl.js +++ b/web/static/js/hitl.js @@ -7,6 +7,19 @@ function hitlModeNormalize(m) { return allowed.indexOf(v) >= 0 ? v : 'off'; } +function hitlT(key, fallback, params) { + const fullKey = 'hitl.' + key; + try { + if (typeof window.t === 'function') { + const translated = window.t(fullKey, params || {}); + if (typeof translated === 'string' && translated && translated !== fullKey) { + return translated; + } + } + } catch (e) {} + return fallback; +} + function hitlEffectiveEnabled(cfg) { if (!cfg) return false; if (cfg.enabled === true) return true; @@ -36,6 +49,18 @@ function hitlSensitiveToolsToArray(config) { return []; } +function normalizeHitlTimeoutSeconds(v, fallback) { + const n = Number(v); + if (Number.isFinite(n)) { + return n > 0 ? Math.floor(n) : 0; + } + const f = Number(fallback); + if (Number.isFinite(f)) { + return f > 0 ? Math.floor(f) : 0; + } + return 0; +} + function getCurrentConversationIdForHitl() { if (typeof window.currentConversationId === 'string' && window.currentConversationId) { return window.currentConversationId; @@ -84,6 +109,7 @@ async function saveHitlConversationConfig(conversationId, config) { const mode = hitlModeNormalize(config.mode || 'off'); const enabled = typeof config.enabled === 'boolean' ? config.enabled : (mode !== 'off'); const sensitiveTools = hitlSensitiveToolsToArray(config); + const timeoutSeconds = normalizeHitlTimeoutSeconds(config.timeoutSeconds, 0); const resp = await hitlApiFetch('/api/hitl/config', { method: 'PUT', credentials: 'same-origin', @@ -93,7 +119,7 @@ async function saveHitlConversationConfig(conversationId, config) { enabled: enabled, mode: mode, sensitiveTools: sensitiveTools, - timeoutSeconds: config.timeoutSeconds || 300 + timeoutSeconds: timeoutSeconds }) }); if (!resp.ok) { @@ -126,7 +152,7 @@ async function syncHitlConfigFromServer(conversationId) { enabled: true, mode: localMode, sensitiveTools: localToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean), - timeoutSeconds: cfg.timeoutSeconds || 300 + timeoutSeconds: normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0) }; saveHitlConversationConfig(conversationId, { mode: localMode, @@ -146,7 +172,7 @@ async function syncHitlConfigFromServer(conversationId) { enabled: true, mode: glMode, sensitiveTools: glToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean), - timeoutSeconds: cfg.timeoutSeconds || 300 + timeoutSeconds: normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0) }; saveHitlConversationConfig(conversationId, { mode: glMode, @@ -265,7 +291,7 @@ async function followAgentRunAfterHitlDecision(conversationId) { async function refreshHitlPending() { const container = document.getElementById('hitl-pending-list'); if (!container) return; - container.innerHTML = '
Loading...
'; + container.innerHTML = '
' + escapeHtml(hitlT('loading', 'Loading...')) + '
'; try { const resp = await hitlApiFetch('/api/hitl/pending', { credentials: 'same-origin' }); if (!resp.ok) { @@ -274,7 +300,7 @@ async function refreshHitlPending() { const data = await resp.json(); const items = Array.isArray(data.items) ? data.items : []; if (!items.length) { - container.innerHTML = '
暂无待审批项
'; + container.innerHTML = '
' + escapeHtml(hitlT('emptyState', 'No pending approvals')) + '
'; return; } container.innerHTML = items.map(function (item) { @@ -292,25 +318,25 @@ async function refreshHitlPending() { '' + escapeHtml(item.toolName || '-') + '' + '' + escapeHtml(item.mode || '-') + '' + '' + - '' + + '' + '' + - '
会话:' + escapeHtml(item.conversationId || '-') + '
' + + '
' + escapeHtml(hitlT('conversationLabel', 'Conversation:')) + ' ' + escapeHtml(item.conversationId || '-') + '
' + '
' + escapeHtml(preview) + '
' + (allowEdit - ? ('
审查编辑模式:可填写 JSON 对象覆盖参数。示例:{"command":"ls -la"}
' + + ? ('
' + escapeHtml(hitlT('reviewEditHelp', 'Review & edit mode: provide a JSON object to override tool arguments. Example: {"command":"ls -la"}')) + '
' + '') - : '
审批模式:仅通过/拒绝,不支持改参。
') + - '
备注(可选):建议写审批依据。
' + - '' + + : '
' + escapeHtml(hitlT('approvalHelp', 'Approval mode: only approve/reject, argument editing is disabled.')) + '
') + + '
' + escapeHtml(hitlT('commentHelp', 'Comment (optional): briefly note the approval reason.')) + '
' + + '' + '
' + - '' + - '' + + '' + + '' + '
' + '' ); }).join(''); } catch (e) { - container.innerHTML = '
加载失败
'; + container.innerHTML = '
' + escapeHtml(hitlT('loadFailed', 'Failed to load')) + '
'; } } @@ -323,7 +349,7 @@ async function submitHitlDecision(interruptId, decision, conversationIdOpt) { try { editedArguments = JSON.parse(editBox.value.trim()); } catch (e) { - alert('JSON 参数格式错误'); + alert(hitlT('invalidJson', 'Invalid JSON arguments')); return; } } @@ -344,7 +370,7 @@ async function submitHitlDecisionWithPayload(interruptId, decision, comment, edi await dismissHitlItem(interruptId, true); return true; } - alert('提交失败:' + errText); + alert(hitlT('submitFailedPrefix', 'Submit failed:') + ' ' + errText); return false; } refreshHitlPending(); diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 61081bff..851518ca 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -2348,9 +2348,28 @@ function renderActiveTasks(tasks) { bar.style.display = 'flex'; bar.innerHTML = ''; + function openActiveTaskConversation(conversationId) { + if (!conversationId) return; + if (typeof switchPage === 'function') { + switchPage('chat'); + } + if (typeof window.loadConversation === 'function') { + setTimeout(function () { + window.loadConversation(conversationId); + }, 120); + return; + } + window.location.hash = 'chat?conversation=' + encodeURIComponent(conversationId); + } + normalizedTasks.forEach(task => { const item = document.createElement('div'); - item.className = 'active-task-item'; + item.className = 'active-task-item active-task-item-clickable'; + if (task && task.conversationId) { + item.title = (typeof window.t === 'function' ? window.t('tasks.viewConversation') : '查看会话'); + item.setAttribute('role', 'button'); + item.onclick = () => openActiveTaskConversation(task.conversationId); + } const startedTime = task.startedAt ? new Date(task.startedAt) : null; const taskTimeLocale = getCurrentTimeLocale(); @@ -2388,7 +2407,10 @@ function renderActiveTasks(tasks) { if (!isFinalStatus) { const cancelBtn = item.querySelector('.active-task-cancel'); if (cancelBtn) { - cancelBtn.onclick = () => cancelActiveTask(task.conversationId, cancelBtn); + cancelBtn.onclick = (evt) => { + evt.stopPropagation(); + cancelActiveTask(task.conversationId, cancelBtn); + }; if (task.status === 'cancelling') { cancelBtn.disabled = true; cancelBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...'; diff --git a/web/static/js/notifications.js b/web/static/js/notifications.js new file mode 100644 index 00000000..68886c0b --- /dev/null +++ b/web/static/js/notifications.js @@ -0,0 +1,321 @@ +(function () { + const STORAGE_LAST_SEEN_KEY = 'cyberstrike-notification-last-seen-at'; + const POLL_INTERVAL_ACTIVE_MS = 15000; + const POLL_INTERVAL_HIDDEN_MS = 60000; + const MAX_RENDER_ITEMS = 20; + + const state = { + inFlight: false, + timerId: null, + dropdownOpen: false, + lastSeenAt: readLastSeenAt(), + items: [], + unreadCount: 0, + }; + + function readLastSeenAt() { + try { + const raw = localStorage.getItem(STORAGE_LAST_SEEN_KEY); + const n = Number(raw); + if (Number.isFinite(n) && n > 0) return n; + } catch (e) { + console.warn('读取通知已读时间失败:', e); + } + return 0; + } + + function persistLastSeenAt(ts) { + try { + localStorage.setItem(STORAGE_LAST_SEEN_KEY, String(ts)); + } catch (e) { + console.warn('保存通知已读时间失败:', e); + } + } + + function getTimeMs(value) { + if (!value) return 0; + const d = new Date(value); + const ms = d.getTime(); + return Number.isFinite(ms) ? ms : 0; + } + + function getLocale() { + if (typeof window !== 'undefined') { + if (typeof window.__locale === 'string' && window.__locale) { + return window.__locale; + } + if (typeof window.currentLang === 'string' && window.currentLang) { + return window.currentLang; + } + } + return 'zh-CN'; + } + + function formatTime(value) { + const ms = getTimeMs(value); + if (!ms) return '-'; + return new Date(ms).toLocaleString(getLocale()); + } + + function htmlEscape(value) { + if (typeof window.escapeHtml === 'function') { + return window.escapeHtml(value == null ? '' : String(value)); + } + const div = document.createElement('div'); + div.textContent = value == null ? '' : String(value); + return div.innerHTML; + } + + function t(key, fallback, params) { + if (typeof window !== 'undefined' && typeof window.t === 'function') { + try { + const translated = window.t(key, params || {}); + if (translated && translated !== key) return translated; + } catch (_ignored) {} + } + return fallback; + } + + async function apiJson(url, options) { + if (typeof window.apiFetch !== 'function') return null; + const res = await window.apiFetch(url, options || {}); + if (!res.ok) return null; + return res.json(); + } + + async function fetchNotificationSummary() { + const url = '/api/notifications/summary?since=' + + encodeURIComponent(String(state.lastSeenAt || 0)) + + '&limit=80&lang=' + encodeURIComponent(getLocale()); + try { + const summary = await apiJson(url); + if (summary && typeof summary === 'object') { + return summary; + } + } catch (_ignored) {} + return null; + } + + function renderBadge(count) { + const badge = document.getElementById('notification-badge'); + const btn = document.getElementById('notification-bell-btn'); + if (!badge || !btn) return; + if (count <= 0) { + badge.style.display = 'none'; + btn.classList.remove('has-alert'); + return; + } + const text = count > 99 ? '99+' : String(count); + badge.innerHTML = '' + htmlEscape(text) + ''; + badge.style.display = 'inline-block'; + btn.classList.add('has-alert'); + } + + function countP0(items) { + return (Array.isArray(items) ? items : []).reduce((acc, item) => { + if (!item || item.level !== 'p0') return acc; + if (typeof item.count === 'number' && item.count > 0) return acc + item.count; + return acc + 1; + }, 0); + } + + function markableItems(items) { + return (Array.isArray(items) ? items : []).filter(item => item && item.actionable !== true && item.id); + } + + function hasAction(item) { + if (!item || !item.type) return false; + if (item.type === 'vulnerability_created' && item.vulnerabilityId) return true; + if ((item.type === 'task_completed' || item.type === 'long_running_tasks') && item.conversationId) return true; + if (item.type === 'task_failed' && item.executionId) return true; + if (item.type === 'hitl_pending') return true; + return false; + } + + function openNotificationTarget(item) { + if (!item || !item.type) return; + if (item.type === 'vulnerability_created' && item.vulnerabilityId) { + window.location.hash = 'vulnerabilities?id=' + encodeURIComponent(item.vulnerabilityId); + return; + } + if ((item.type === 'task_completed' || item.type === 'long_running_tasks') && item.conversationId) { + window.location.hash = 'chat?conversation=' + encodeURIComponent(item.conversationId); + return; + } + if (item.type === 'task_failed' && item.executionId) { + window.location.hash = 'mcp-monitor'; + setTimeout(function () { + if (typeof showMCPDetail === 'function') { + showMCPDetail(item.executionId); + } + }, 450); + return; + } + if (item.type === 'hitl_pending') { + window.location.hash = 'hitl'; + } + } + + async function markItemsRead(eventIds) { + if (!Array.isArray(eventIds) || !eventIds.length) return true; + const payload = { eventIds: eventIds }; + try { + const result = await apiJson('/api/notifications/read', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return !!result; + } catch (_ignored) { + return false; + } + } + + function renderNotificationList(items) { + const list = document.getElementById('notification-list'); + if (!list) return; + const renderItems = Array.isArray(items) ? items.slice(0, MAX_RENDER_ITEMS) : []; + if (!renderItems.length) { + list.innerHTML = '
' + htmlEscape(t('notifications.empty', '暂无新事件')) + '
'; + return; + } + const html = renderItems.map(item => { + const canMarkRead = item.actionable !== true && !!item.id; + const canView = hasAction(item); + return ` +
+
+
${htmlEscape(item.title || t('notifications.itemDefaultTitle', '通知'))}
+
+ ${canView ? `` : ''} + ${canMarkRead ? `` : ''} +
+
+
${htmlEscape(item.desc || '')}
+
${htmlEscape(formatTime(item.ts))}
+
+ `; + }).join(''); + list.innerHTML = html; + const viewButtons = list.querySelectorAll('.notification-item-view-btn'); + viewButtons.forEach(btn => { + btn.addEventListener('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + const eventID = btn.getAttribute('data-action-id') || ''; + if (!eventID) return; + const item = state.items.find(it => it && it.id === eventID); + if (!item) return; + openNotificationTarget(item); + closeDropdown(); + }); + }); + const readButtons = list.querySelectorAll('.notification-item-read-btn'); + readButtons.forEach(btn => { + btn.addEventListener('click', async function (event) { + event.preventDefault(); + event.stopPropagation(); + const eventID = btn.getAttribute('data-notification-id') || ''; + if (!eventID) return; + const ok = await markItemsRead([eventID]); + if (ok) { + await refreshNotifications(); + } + }); + }); + } + + function closeDropdown() { + const dropdown = document.getElementById('notification-dropdown'); + const bellBtn = document.getElementById('notification-bell-btn'); + if (dropdown) dropdown.style.display = 'none'; + if (bellBtn) bellBtn.classList.remove('active'); + state.dropdownOpen = false; + } + + function markSeenNow() { + state.lastSeenAt = Date.now(); + persistLastSeenAt(state.lastSeenAt); + } + + async function refreshNotifications() { + if (state.inFlight) return; + state.inFlight = true; + try { + const summary = await fetchNotificationSummary(); + const items = summary && Array.isArray(summary.items) ? summary.items : []; + state.items = items; + const unreadCount = summary && Number.isFinite(Number(summary.unreadCount)) + ? Number(summary.unreadCount) + : countP0(items); + state.unreadCount = Math.max(0, unreadCount); + renderBadge(state.unreadCount); + renderNotificationList(items); + } catch (e) { + console.warn('刷新通知失败:', e); + } finally { + state.inFlight = false; + } + } + + function scheduleNextPoll() { + if (state.timerId) { + window.clearTimeout(state.timerId); + state.timerId = null; + } + const interval = document.hidden ? POLL_INTERVAL_HIDDEN_MS : POLL_INTERVAL_ACTIVE_MS; + state.timerId = window.setTimeout(async function () { + await refreshNotifications(); + scheduleNextPoll(); + }, interval); + } + + function handleDocumentClick(event) { + const container = document.querySelector('.notification-menu-container'); + if (!container) return; + if (!container.contains(event.target)) { + closeDropdown(); + } + } + + async function toggleDropdown() { + const dropdown = document.getElementById('notification-dropdown'); + const bellBtn = document.getElementById('notification-bell-btn'); + if (!dropdown || !bellBtn) return; + const isOpen = dropdown.style.display !== 'none'; + if (isOpen) { + closeDropdown(); + return; + } + dropdown.style.display = 'block'; + bellBtn.classList.add('active'); + state.dropdownOpen = true; + await refreshNotifications(); + } + + async function markAllSeen() { + const ids = markableItems(state.items).map(item => item.id); + const ok = await markItemsRead(ids); + if (ok) { + markSeenNow(); + await refreshNotifications(); + } + } + + function initNotifications() { + const bellBtn = document.getElementById('notification-bell-btn'); + if (!bellBtn) return; + document.addEventListener('click', handleDocumentClick); + document.addEventListener('visibilitychange', scheduleNextPoll); + document.addEventListener('languagechange', function () { + refreshNotifications(); + }); + refreshNotifications(); + scheduleNextPoll(); + } + + window.toggleNotificationDropdown = toggleDropdown; + window.markAllNotificationsSeen = markAllSeen; + + document.addEventListener('DOMContentLoaded', initNotifications); +})(); diff --git a/web/static/js/vulnerability.js b/web/static/js/vulnerability.js index df2595e9..67df391f 100644 --- a/web/static/js/vulnerability.js +++ b/web/static/js/vulnerability.js @@ -61,7 +61,7 @@ let vulnerabilityPagination = { totalPages: 1 }; -// 从地址栏 #vulnerabilities?conversation_id= / ?task_id= 同步筛选(对话菜单、任务管理联动) +// 从地址栏 #vulnerabilities?conversation_id= / ?task_id= / ?id= 同步筛选(通知/对话菜单/任务管理联动) function syncVulnerabilityFiltersFromLocationHash() { const hash = window.location.hash.slice(1); const hashParts = hash.split('?'); @@ -69,19 +69,27 @@ function syncVulnerabilityFiltersFromLocationHash() { return; } const params = new URLSearchParams(hashParts.slice(1).join('?')); + const vid = (params.get('id') || '').trim(); const cid = (params.get('conversation_id') || '').trim(); const tid = (params.get('task_id') || '').trim(); - if (!cid && !tid) { + if (!vid && !cid && !tid) { return; } + vulnerabilityFilters.id = ''; vulnerabilityFilters.conversation_id = ''; vulnerabilityFilters.task_id = ''; + const idEl = document.getElementById('vulnerability-id-filter'); const convEl = document.getElementById('vulnerability-conversation-filter'); const taskEl = document.getElementById('vulnerability-task-filter'); + if (idEl) idEl.value = ''; if (convEl) convEl.value = ''; if (taskEl) taskEl.value = ''; + if (vid) { + vulnerabilityFilters.id = vid; + if (idEl) idEl.value = vid; + } if (cid) { vulnerabilityFilters.conversation_id = cid; if (convEl) convEl.value = cid; @@ -334,6 +342,13 @@ function renderVulnerabilities(vulnerabilities) { if (typeof window.applyTranslations === 'function') { window.applyTranslations(listContainer); } + + // 如果通过漏洞ID筛选且只返回一条记录,自动展开详情(提升“点击查看”的用户体验) + if (vulnerabilities.length === 1 && vulnerabilityFilters.id && vulnerabilityFilters.id === vulnerabilities[0].id) { + setTimeout(() => { + toggleVulnerabilityDetails(vulnerabilities[0].id); + }, 300); + } } // 渲染分页控件 diff --git a/web/templates/index.html b/web/templates/index.html index a1316aad..2f04ae72 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -63,6 +63,24 @@
English
+
+ + +