Files
CyberStrikeAI/web/static/js/notifications.js
T
2026-04-30 10:43:23 +08:00

330 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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 = '<span class="notification-badge-text">' + htmlEscape(text) + '</span>';
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 = '<div class="notification-empty">' + htmlEscape(t('notifications.empty', '暂无新事件')) + '</div>';
return;
}
const html = renderItems.map(item => {
const canMarkRead = item.actionable !== true && !!item.id;
const canView = hasAction(item);
return `
<div class="notification-item notification-level-${htmlEscape(item.level || 'p2')}">
<div class="notification-item-header">
<div class="notification-item-title">${htmlEscape(item.title || t('notifications.itemDefaultTitle', '通知'))}</div>
<div class="notification-item-actions">
${canView ? `<button class="notification-item-action-btn notification-item-view-btn" type="button" data-action-id="${htmlEscape(item.id || '')}">${htmlEscape(t('common.view', '查看'))}</button>` : ''}
${canMarkRead ? `<button class="notification-item-action-btn notification-item-read-btn" type="button" data-notification-id="${htmlEscape(item.id)}">${htmlEscape(t('notifications.markSingleRead', '已读'))}</button>` : ''}
</div>
</div>
<div class="notification-item-desc">${htmlEscape(item.desc || '')}</div>
<div class="notification-item-time">${htmlEscape(formatTime(item.ts))}</div>
</div>
`;
}).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);
})();