auto-sync: 2026-05-20 22:38:29

This commit is contained in:
cfdaily
2026-05-20 22:38:29 +08:00
parent af4fad5525
commit 806da277f1
@@ -1,18 +1,20 @@
/**
* 通知中心 — 烽火台
* 显示系统通知、审批请求、告警信息
* 数据来源:未读邮件 + SSE 实时事件
*/
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useStore } from '../store';
export interface Notification {
interface NotifItem {
id: string;
type: 'info' | 'warning' | 'success' | 'error';
title: string;
message: string;
time: string;
read: boolean;
source: 'mail' | 'event';
actionUrl?: string;
}
const TYPE_STYLES: Record<string, { icon: string; color: string }> = {
@@ -22,44 +24,122 @@ const TYPE_STYLES: Record<string, { icon: string; color: string }> = {
error: { icon: '🚨', color: '#ff5270' },
};
function mailToNotif(m: any): NotifItem {
const typeMap: Record<string, NotifItem['type']> = {
inform: 'info',
task: 'info',
alert: 'warning',
error: 'error',
done: 'success',
};
return {
id: m.id,
type: typeMap[m.type] || 'info',
title: m.title || '(无标题)',
message: `${m.from || '?'}${m.to || '?'}`,
time: m.created_at || '',
read: !!m.is_read,
source: 'mail',
};
}
export default function NotificationCenter({ onClose }: { onClose: () => void }) {
const [notifications] = useState<Notification[]>([]);
const unread = notifications.filter((n) => !n.read).length;
const mails = useStore(s => s.mails);
const mailUnread = useStore(s => s.mailUnread);
const loadMails = useStore(s => s.loadMails);
const [sseEvents, setSseEvents] = useState<NotifItem[]>([]);
// 加载邮件
useEffect(() => {
loadMails();
}, []);
// SSE 实时事件
useEffect(() => {
const es = new EventSource('/api/events');
es.onmessage = (e) => {
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 => [{
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,
message: data.detail || JSON.stringify(data).slice(0, 100),
time: data.timestamp || new Date().toISOString(),
read: false,
source: 'event',
}, ...prev].slice(0, 20)]);
}
} catch { /* ignore parse errors */ }
};
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 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 */ }
}
};
return (
<div className="confirm-bg open" onClick={onClose}>
<div className="confirm-box" style={{ maxWidth: 400, maxHeight: 500 }} 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>
{unread > 0 && (
<span style={{ fontSize: 10, padding: '2px 8px', borderRadius: 10, background: '#ff5270', color: '#fff', fontWeight: 600 }}>
{unread}
</span>
)}
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{unread > 0 && (
<span style={{ fontSize: 10, padding: '2px 8px', borderRadius: 10, background: '#ff5270', color: '#fff', fontWeight: 600 }}>
{unread}
</span>
)}
<span style={{ fontSize: 11, color: 'var(--muted)', cursor: 'pointer' }} onClick={onClose}></span>
</div>
</div>
{notifications.length === 0 ? (
{allNotifs.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ fontSize: 28, marginBottom: 8 }}>🔕</div>
<div style={{ fontSize: 12, color: 'var(--muted)' }}></div>
<div style={{ fontSize: 12, color: 'var(--muted)' }}></div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 380, overflow: 'auto' }}>
{notifications.map((n) => {
<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: 10, color: 'var(--muted)' }}>{n.time}</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>