Files
memind/src/components/NotificationCenter.tsx
T
john 32fb2cdeaf feat: chat uploads, vision turn isolation, and MindSpace agent improvements
Add chat file/image upload UX, attachment proxying, vision thumbnails, and per-turn image scoping so agents only use the current upload. Extend MindSpace asset context, billing token state, OA/scenario verify scripts, and related runtime config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 00:23:01 +08:00

277 lines
9.8 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import {
clearNotifications,
deleteNotification,
listNotifications,
markAllNotificationsRead,
markNotificationRead,
subscribeNotificationEvents,
} from '../api/client';
import type { UserNotification } from '../types';
import { isRechargeNotification, requestBalanceRefresh } from '../utils/balanceRefresh';
const POPOVER_WIDTH = 340;
const HEADER_POPOVER_OPEN_EVENT = 'tkmind:header-popover-open';
const HEADER_POPOVER_ID = 'notification-center';
function formatTime(timestamp: number) {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return '';
return `${date.getMonth() + 1}/${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
type Filter = 'all' | 'unread' | 'read';
function parseNotificationLines(body: string) {
return body
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
export function NotificationCenter({
onOpenRecharge,
}: {
onOpenRecharge?: () => void;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState<Filter>('all');
const [items, setItems] = useState<UserNotification[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
const wrapRef = useRef<HTMLDivElement>(null);
const unreadCount = useMemo(
() => items.reduce((count, item) => count + (item.status === 'unread' ? 1 : 0), 0),
[items],
);
const load = async (nextFilter = filter) => {
setLoading(true);
setError(null);
try {
const notifications = await listNotifications(nextFilter, 50);
setItems(notifications);
} catch (err) {
setError(err instanceof Error ? err.message : '读取通知失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
const syncAll = () => {
if (document.visibilityState === 'visible') {
void load('all');
}
};
void load('all');
const unsubscribe = subscribeNotificationEvents({
onNotification: (notification) => {
if (isRechargeNotification(notification)) {
requestBalanceRefresh();
}
void load('all');
},
onSync: () => void load('all'),
});
window.addEventListener('focus', syncAll);
document.addEventListener('visibilitychange', syncAll);
return () => {
unsubscribe();
window.removeEventListener('focus', syncAll);
document.removeEventListener('visibilitychange', syncAll);
};
}, []);
useEffect(() => {
if (!open) return;
const handleClick = (event: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [open]);
useEffect(() => {
const handleOtherPopoverOpen = (event: Event) => {
const detail = (event as CustomEvent<{ id?: string }>).detail;
if (detail?.id && detail.id !== HEADER_POPOVER_ID) {
setOpen(false);
}
};
window.addEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
return () => window.removeEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
}, []);
useEffect(() => {
if (!open) return;
const updatePosition = () => {
const anchor = wrapRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const width = Math.min(POPOVER_WIDTH, window.innerWidth - 24);
let left = rect.left + rect.width / 2 - width / 2;
left = Math.max(12, Math.min(left, window.innerWidth - width - 12));
setPopoverStyle({
position: 'fixed',
top: rect.bottom + 10,
left,
width,
});
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [open]);
const visibleItems = filter === 'all' ? items : items.filter((item) => item.status === filter);
return (
<div className={`notification-center${open ? ' open' : ''}`} ref={wrapRef}>
<button
type="button"
className="header-icon-btn notification-center-trigger"
aria-label="通知中心"
aria-expanded={open}
onClick={() => {
setOpen((value) => {
const next = !value;
if (next) {
window.dispatchEvent(
new CustomEvent(HEADER_POPOVER_OPEN_EVENT, {
detail: { id: HEADER_POPOVER_ID },
}),
);
}
return next;
});
if (!open) void load(filter);
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 4a5 5 0 00-5 5v2.7c0 .5-.18.97-.5 1.33L5 15h14l-1.5-1.97a2.1 2.1 0 01-.5-1.33V9a5 5 0 00-5-5z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M10 18a2 2 0 004 0" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
{unreadCount > 0 && <span className="notification-center-badge">{unreadCount > 99 ? '99+' : unreadCount}</span>}
</button>
{open && (
<div className="notification-center-popover" role="dialog" aria-label="通知中心" style={popoverStyle}>
<div className="notification-center-head">
<div>
<h4></h4>
<p>{unreadCount > 0 ? `${unreadCount} 条未读` : '通知已处理完'}</p>
</div>
<div className="notification-center-actions">
<button type="button" className="ghost-btn" onClick={() => void markAllNotificationsRead().then(() => load(filter))}>
</button>
<button
type="button"
className="notification-center-clear-all-btn"
onClick={() => void clearNotifications(filter).then(() => load(filter))}
>
</button>
</div>
</div>
<div className="notification-center-filters">
{(['all', 'unread', 'read'] as Filter[]).map((value) => (
<button
key={value}
type="button"
className={`notification-center-filter${filter === value ? ' is-active' : ''}`}
onClick={() => {
setFilter(value);
void load(value);
}}
>
{value === 'all' ? '全部' : value === 'unread' ? '未读' : '已读'}
</button>
))}
</div>
{loading ? <p className="notification-center-state"></p> : null}
{error ? <p className="notification-center-state is-error">{error}</p> : null}
{!loading && !error && visibleItems.length === 0 ? <p className="notification-center-state"></p> : null}
<div className="notification-center-list">
{visibleItems.map((item) => (
<article key={item.id} className={`notification-center-item${item.status === 'unread' ? ' is-unread' : ''}`}>
<div className="notification-center-item-head">
<strong>{item.title}</strong>
<span>{formatTime(item.createdAt)}</span>
</div>
{item.notificationType === 'todo_digest' ? (
<div className="notification-center-digest">
{parseNotificationLines(item.body).map((line, index) => (
<div
key={`${item.id}-${index}`}
className={`notification-center-digest-line${index === 0 ? ' is-title' : ''}`}
>
{line}
</div>
))}
</div>
) : (
<p>{item.body}</p>
)}
<div className="notification-center-item-actions">
{item.status === 'unread' ? (
<button
type="button"
className="notification-center-read-btn"
onClick={() => void markNotificationRead(item.id).then(() => load(filter))}
>
</button>
) : (
<span className="notification-center-read-tag"></span>
)}
{item.notificationType === 'balance_low' && onOpenRecharge ? (
<button
type="button"
className="ghost-btn"
onClick={() => {
if (item.status === 'unread') {
void markNotificationRead(item.id).then(() => load(filter));
}
setOpen(false);
onOpenRecharge();
}}
>
</button>
) : null}
<button
type="button"
className="notification-center-clear-btn"
onClick={() => void deleteNotification(item.id).then(() => load(filter))}
>
</button>
</div>
</article>
))}
</div>
</div>
)}
</div>
);
}