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.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
+