auto-sync: 2026-05-20 22:38:29
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user