Add smart ACK provider for WeChat MP replies
Replace fixed ackText with a rule-based AckProvider that picks response templates by message type and intent (translate, summary, rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync, zero I/O, auto-falls back to config.ackText on any error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
clearNotifications,
|
||||
deleteNotification,
|
||||
listNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
subscribeNotificationEvents,
|
||||
} from '../api/client';
|
||||
import type { UserNotification } from '../types';
|
||||
|
||||
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: () => 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user