(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; } // 从仪表盘「查看全部」等容器外入口打开时,同一 click 会冒泡到 document, // handleDocumentClick 会误判为「点在外面」并立刻关掉。推迟到宏任务再展开即可。 const runOpen = async function () { if (dropdown.style.display !== 'none') return; dropdown.style.display = 'block'; bellBtn.classList.add('active'); state.dropdownOpen = true; await refreshNotifications(); }; window.setTimeout(function () { void runOpen(); }, 0); } 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); })();