auto-sync: 2026-05-20 23:07:26
This commit is contained in:
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 通知中心 — 烽火台
|
* 通知中心 — 烽火台
|
||||||
* 数据来源:未读邮件 + SSE 实时事件
|
* 数据来源:未读邮件(store.mails)+ SSE 事件(store.sseEvents)
|
||||||
* 按 topic7-9 设计:按级别分组、操作按钮、任务链接、全部已读
|
* 单一 SSE 连接由 store.ts 管理,这里只读数据
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
|
|
||||||
interface NotifItem {
|
interface NotifItem {
|
||||||
@@ -17,7 +17,6 @@ interface NotifItem {
|
|||||||
source: 'mail' | 'event';
|
source: 'mail' | 'event';
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
actionUrl?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEVEL_ORDER: NotifItem['type'][] = ['error', 'warning', 'success', 'info'];
|
const LEVEL_ORDER: NotifItem['type'][] = ['error', 'warning', 'success', 'info'];
|
||||||
@@ -45,45 +44,14 @@ function mailToNotif(m: any): NotifItem {
|
|||||||
|
|
||||||
export default function NotificationCenter({ onClose }: { onClose: () => void }) {
|
export default function NotificationCenter({ onClose }: { onClose: () => void }) {
|
||||||
const mails = useStore(s => s.mails);
|
const mails = useStore(s => s.mails);
|
||||||
|
const sseEvents = useStore(s => s.sseEvents) as NotifItem[];
|
||||||
const loadMails = useStore(s => s.loadMails);
|
const loadMails = useStore(s => s.loadMails);
|
||||||
const [sseEvents, setSseEvents] = useState<NotifItem[]>([]);
|
|
||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => { loadMails(); }, []);
|
useEffect(() => { loadMails(); }, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const es = new EventSource('/api/events');
|
|
||||||
const importantTypes = ['task_completed', 'task_failed', 'review_result', 'agent_completed', 'task_updated'];
|
|
||||||
const handler = (e: MessageEvent) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(e.data);
|
|
||||||
const etype = (e as any).type || data.event_type || '';
|
|
||||||
if (!importantTypes.includes(etype)) return;
|
|
||||||
const ntype = etype === 'task_failed' ? 'error' : etype === 'task_completed' ? 'success' : etype === 'review_result' ? 'warning' : 'info';
|
|
||||||
setSseEvents(prev => {
|
|
||||||
const item: NotifItem = {
|
|
||||||
id: data.id || `sse-${Date.now()}`,
|
|
||||||
type: ntype,
|
|
||||||
title: data.task_id ? `任务 ${data.task_id?.slice(0, 8)}...` : (data.event_type || etype),
|
|
||||||
message: `${data.old_status || ''} → ${data.new_status || data.event_type || ''}`,
|
|
||||||
time: data.timestamp || new Date().toISOString(),
|
|
||||||
read: false,
|
|
||||||
source: 'event',
|
|
||||||
taskId: data.task_id,
|
|
||||||
projectId: data.project_id,
|
|
||||||
};
|
|
||||||
return [item, ...prev].slice(0, 30);
|
|
||||||
});
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
};
|
|
||||||
importantTypes.forEach(t => es.addEventListener(t, handler));
|
|
||||||
es.onerror = () => { es.close(); };
|
|
||||||
return () => { es.close(); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 合并 + 分组
|
// 合并 + 分组
|
||||||
const mailNotifs = (mails || []).slice(0, 20).map(mailToNotif);
|
const mailNotifs = (mails || []).slice(0, 20).map(mailToNotif);
|
||||||
const allNotifs = [...sseEvents, ...mailNotifs];
|
const allNotifs: NotifItem[] = [...(sseEvents || []), ...mailNotifs];
|
||||||
const unread = allNotifs.filter(n => !n.read).length;
|
const unread = allNotifs.filter(n => !n.read).length;
|
||||||
|
|
||||||
const grouped = LEVEL_ORDER.map(type => ({
|
const grouped = LEVEL_ORDER.map(type => ({
|
||||||
@@ -102,12 +70,15 @@ export default function NotificationCenter({ onClose }: { onClose: () => void })
|
|||||||
loadMails();
|
loadMails();
|
||||||
}
|
}
|
||||||
if (notif.source === 'event') {
|
if (notif.source === 'event') {
|
||||||
setSseEvents(prev => prev.map(n => n.id === notif.id ? { ...n, read: true } : n));
|
useStore.setState({
|
||||||
|
sseEvents: (useStore.getState().sseEvents as NotifItem[]).map(
|
||||||
|
n => n.id === notif.id ? { ...n, read: true } : n
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAllRead = async () => {
|
const handleMarkAllRead = async () => {
|
||||||
// 标记所有邮件已读
|
|
||||||
for (const n of allNotifs.filter(n => !n.read && n.source === 'mail')) {
|
for (const n of allNotifs.filter(n => !n.read && n.source === 'mail')) {
|
||||||
await fetch(`/api/mail/${n.id}`, {
|
await fetch(`/api/mail/${n.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -116,7 +87,9 @@ export default function NotificationCenter({ onClose }: { onClose: () => void })
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
loadMails();
|
loadMails();
|
||||||
setSseEvents(prev => prev.map(n => ({ ...n, read: true })));
|
useStore.setState({
|
||||||
|
sseEvents: (useStore.getState().sseEvents as NotifItem[]).map(n => ({ ...n, read: true })),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewTask = (notif: NotifItem) => {
|
const handleViewTask = (notif: NotifItem) => {
|
||||||
@@ -158,15 +131,12 @@ export default function NotificationCenter({ onClose }: { onClose: () => void })
|
|||||||
const s = TYPE_STYLES[n.type];
|
const s = TYPE_STYLES[n.type];
|
||||||
return (
|
return (
|
||||||
<div key={n.id} onClick={() => {
|
<div key={n.id} onClick={() => {
|
||||||
// 直接操作:有任务链接就跳转,否则标记已读
|
|
||||||
if (n.taskId) { handleViewTask(n); }
|
if (n.taskId) { handleViewTask(n); }
|
||||||
else if (!n.read) { handleMarkRead(n); }
|
else if (!n.read) { handleMarkRead(n); }
|
||||||
}} style={{
|
}} style={{
|
||||||
padding: '8px 10px', borderRadius: 8,
|
padding: '8px 10px', borderRadius: 8,
|
||||||
background: 'var(--panel2)', border: `1px solid ${n.read ? 'var(--line)' : s.color + '44'}`,
|
background: 'var(--panel2)', border: `1px solid ${n.read ? 'var(--line)' : s.color + '44'}`,
|
||||||
opacity: n.read ? 0.5 : 1,
|
opacity: n.read ? 0.5 : 1, cursor: 'pointer',
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'opacity 0.15s',
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
<span style={{ fontSize: 12 }}>{s.icon}</span>
|
<span style={{ fontSize: 12 }}>{s.icon}</span>
|
||||||
@@ -174,7 +144,7 @@ export default function NotificationCenter({ onClose }: { onClose: () => void })
|
|||||||
<span style={{ fontSize: 9, background: 'var(--panel)', padding: '1px 4px', borderRadius: 3, color: 'var(--muted)' }}>
|
<span style={{ fontSize: 9, background: 'var(--panel)', padding: '1px 4px', borderRadius: 3, color: 'var(--muted)' }}>
|
||||||
{n.source === 'mail' ? '✉️' : '📡'}
|
{n.source === 'mail' ? '✉️' : '📡'}
|
||||||
</span>
|
</span>
|
||||||
{!n.read && <span style={{ width: 6, height: 6, borderRadius: 3, background: s.color }} />}
|
{!n.read && <span style={{ width: 6, height: 6, borderRadius: 3, background: s.color, display: 'inline-block' }} />}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>{n.message}</div>
|
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>{n.message}</div>
|
||||||
<div style={{ fontSize: 9, color: 'var(--muted)', marginTop: 2, opacity: 0.6 }}>
|
<div style={{ fontSize: 9, color: 'var(--muted)', marginTop: 2, opacity: 0.6 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user