auto-sync: 2026-05-20 22:57:26

This commit is contained in:
cfdaily
2026-05-20 22:57:26 +08:00
parent 681b1151fe
commit 6ae06fd982
@@ -1,6 +1,7 @@
/**
* 通知中心 — 烽火台
* 数据来源:未读邮件 + SSE 实时事件
* 按 topic7-9 设计:按级别分组、操作按钮、任务链接、全部已读
*/
import { useState, useEffect } from 'react';
@@ -14,23 +15,22 @@ interface NotifItem {
time: string;
read: boolean;
source: 'mail' | 'event';
taskId?: string;
projectId?: string;
actionUrl?: string;
}
const TYPE_STYLES: Record<string, { icon: string; color: string }> = {
info: { icon: '️', color: '#6a9eff' },
warning: { icon: '⚠️', color: '#f5c842' },
success: { icon: '', color: '#2ecc8a' },
error: { icon: '🚨', color: '#ff5270' },
const LEVEL_ORDER: NotifItem['type'][] = ['error', 'warning', 'success', 'info'];
const TYPE_STYLES: Record<string, { icon: string; color: string; label: string }> = {
error: { icon: '🚨', color: '#ff5270', label: '🔴 紧急' },
warning: { icon: '⚠️', color: '#f5c842', label: '🟡 警告' },
success: { icon: '', color: '#2ecc8a', label: '🟢 完成' },
info: { icon: '️', color: '#6a9eff', label: '🔵 通知' },
};
function mailToNotif(m: any): NotifItem {
const typeMap: Record<string, NotifItem['type']> = {
inform: 'info',
task: 'info',
alert: 'warning',
error: 'error',
done: 'success',
inform: 'info', task: 'info', alert: 'warning', error: 'error', done: 'success',
};
return {
id: m.id,
@@ -45,75 +45,101 @@ function mailToNotif(m: any): NotifItem {
export default function NotificationCenter({ onClose }: { onClose: () => void }) {
const mails = useStore(s => s.mails);
const mailUnread = useStore(s => s.mailUnread);
const loadMails = useStore(s => s.loadMails);
const [sseEvents, setSseEvents] = useState<NotifItem[]>([]);
const [expanded, setExpanded] = useState<string | null>(null);
// 加载邮件
useEffect(() => {
loadMails();
}, []);
useEffect(() => { loadMails(); }, []);
// SSE 实时事件
useEffect(() => {
const es = new EventSource('/api/events');
es.onmessage = (e) => {
const importantTypes = ['task_completed', 'task_failed', 'review_result', 'agent_completed', 'task_updated'];
const handler = (e: MessageEvent) => {
try {
const data = JSON.parse(e.data);
// 只关注关键事件
const importantTypes = [
'task_completed', 'task_failed', 'review_result',
'agent_completed', 'agent_spawned',
];
if (importantTypes.includes(e.type || data.event_type)) {
setSseEvents(prev => {
const item: NotifItem = {
id: data.id || `sse-${Date.now()}`,
type: e.type === 'task_failed' ? 'error' : e.type === 'task_completed' ? 'success' : 'info',
title: data.title || data.event_type || e.type || 'Event',
message: data.detail || JSON.stringify(data).slice(0, 100),
time: data.timestamp || new Date().toISOString(),
read: false,
source: 'event',
};
return [item, ...prev].slice(0, 20);
});
}
} catch { /* ignore parse errors */ }
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(); };
}, []);
// 合并:邮件 + SSE 事件
// 合并 + 分组
const mailNotifs = (mails || []).slice(0, 20).map(mailToNotif);
const allNotifs = [...sseEvents, ...mailNotifs];
const unread = allNotifs.filter(n => !n.read).length;
const grouped = LEVEL_ORDER.map(type => ({
type,
label: TYPE_STYLES[type].label,
items: allNotifs.filter(n => n.type === type),
})).filter(g => g.items.length > 0);
const handleMarkRead = async (notif: NotifItem) => {
if (notif.source === 'mail' && !notif.read) {
try {
await fetch(`/api/mail/${notif.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_read: true }),
});
loadMails();
} catch { /* ignore */ }
await fetch(`/api/mail/${notif.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_read: true }),
});
loadMails();
}
if (notif.source === 'event') {
setSseEvents(prev => prev.map(n => n.id === notif.id ? { ...n, read: true } : n));
}
};
const handleMarkAllRead = async () => {
// 标记所有邮件已读
for (const n of allNotifs.filter(n => !n.read && n.source === 'mail')) {
await fetch(`/api/mail/${n.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_read: true }),
});
}
loadMails();
setSseEvents(prev => prev.map(n => ({ ...n, read: true })));
};
const handleViewTask = (notif: NotifItem) => {
if (notif.taskId) {
useStore.getState().setModalTaskId(notif.taskId);
if (notif.projectId) useStore.getState().setSelectedProjectId(notif.projectId);
onClose();
}
};
return (
<div className="confirm-bg open" onClick={onClose}>
<div className="confirm-box" style={{ maxWidth: 420, maxHeight: 520 }} onClick={(e) => e.stopPropagation()}>
<div className="confirm-box" style={{ maxWidth: 420, maxHeight: 520 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontSize: 14, fontWeight: 700 }}>🔔 </span>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{unread > 0 && (
<span style={{ fontSize: 10, padding: '2px 8px', borderRadius: 10, background: '#ff5270', color: '#fff', fontWeight: 600 }}>
{unread}
</span>
<>
<span style={{ fontSize: 10, padding: '2px 8px', borderRadius: 10, background: '#ff5270', color: '#fff', fontWeight: 600 }}>{unread}</span>
<button onClick={handleMarkAllRead} style={{ fontSize: 10, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--line)', background: 'transparent', color: 'var(--muted)', cursor: 'pointer' }}></button>
</>
)}
<span style={{ fontSize: 11, color: 'var(--muted)', cursor: 'pointer' }} onClick={onClose}></span>
<span style={{ fontSize: 13, color: 'var(--muted)', cursor: 'pointer' }} onClick={onClose}></span>
</div>
</div>
@@ -123,31 +149,44 @@ export default function NotificationCenter({ onClose }: { onClose: () => void })
<div style={{ fontSize: 12, color: 'var(--muted)' }}></div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 400, overflow: 'auto' }}>
{allNotifs.map((n) => {
const style = TYPE_STYLES[n.type] || TYPE_STYLES.info;
return (
<div
key={n.id}
onClick={() => handleMarkRead(n)}
style={{
padding: '10px 12px', borderRadius: 8,
background: 'var(--panel2)', border: `1px solid ${n.read ? 'var(--line)' : style.color + '44'}`,
opacity: n.read ? 0.6 : 1,
cursor: n.source === 'mail' && !n.read ? 'pointer' : 'default',
}}
>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<span>{style.icon}</span>
<span style={{ fontSize: 12, fontWeight: 600 }}>{n.title}</span>
<span style={{ marginLeft: 'auto', fontSize: 9, color: 'var(--muted)', background: 'var(--panel)', padding: '1px 5px', borderRadius: 4 }}>
{n.source === 'mail' ? '✉️' : '📡'}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{n.message}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, maxHeight: 400, overflow: 'auto' }}>
{grouped.map(g => (
<div key={g.type}>
<div style={{ fontSize: 11, fontWeight: 600, color: TYPE_STYLES[g.type].color, marginBottom: 4 }}>{g.label} ({g.items.length})</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{g.items.map(n => {
const s = TYPE_STYLES[n.type];
const isExpanded = expanded === n.id;
return (
<div key={n.id} style={{
padding: '8px 10px', borderRadius: 8,
background: 'var(--panel2)', border: `1px solid ${n.read ? 'var(--line)' : s.color + '44'}`,
opacity: n.read ? 0.5 : 1,
}}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', cursor: 'pointer' }} onClick={() => setExpanded(isExpanded ? null : n.id)}>
<span style={{ fontSize: 12 }}>{s.icon}</span>
<span style={{ fontSize: 11, fontWeight: 600, flex: 1 }}>{n.title}</span>
<span style={{ fontSize: 9, background: 'var(--panel)', padding: '1px 4px', borderRadius: 3, color: 'var(--muted)' }}>
{n.source === 'mail' ? '✉️' : '📡'}
</span>
</div>
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>{n.message}</div>
{isExpanded && (
<div style={{ display: 'flex', gap: 6, marginTop: 6 }}>
{n.taskId && (
<button onClick={() => handleViewTask(n)} style={{ fontSize: 10, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--acc)', background: 'transparent', color: 'var(--acc)', cursor: 'pointer' }}></button>
)}
{!n.read && (
<button onClick={() => handleMarkRead(n)} style={{ fontSize: 10, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--line)', background: 'transparent', color: 'var(--muted)', cursor: 'pointer' }}></button>
)}
</div>
)}
</div>
);
})}
</div>
);
})}
</div>
))}
</div>
)}
</div>