32fb2cdeaf
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>
277 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|