吸收数字员工通知投影

This commit is contained in:
baiyanyun
2026-06-09 18:33:01 +08:00
parent dc8cd09535
commit 23c3577ef4
10 changed files with 617 additions and 149 deletions

View File

@@ -1,7 +1,15 @@
import { useCallback, useEffect, useState } from 'react';
import { Bell, Mail, X } from 'lucide-react';
import { Bell, Check, Mail, MessageSquare, X } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import { RoleProvider } from '@/contexts/RoleContext';
import {
dismissNotification,
getNotifications,
getUnreadNotificationCount,
markNotificationRead,
replyToNotification,
type UserNotification,
} from '@/lib/api';
import CommandDeck from './CommandDeck';
import MissionCenter from './MissionCenter';
import SkillLibrary from './SkillLibrary';
@@ -68,15 +76,61 @@ function closeDigitalWindow() {
window.close();
}
function notificationLevelLabel(level: UserNotification['level']): string {
if (level === 'action') return '待处理';
if (level === 'error') return '异常';
if (level === 'warn') return '提醒';
return '消息';
}
function notificationStatusLabel(status: UserNotification['status']): string {
if (status === 'resolved') return '已解决';
if (status === 'dismissed') return '已忽略';
if (status === 'read') return '已读';
return '未读';
}
function notificationTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(date);
}
export default function DigitalEmployeeShell() {
const location = useLocation();
const navigate = useNavigate();
const [target, setTarget] = useState<DigitalNavigationTarget>(() => targetFromLocation(location.pathname, location.search));
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [notifications, setNotifications] = useState<UserNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [notificationsLoading, setNotificationsLoading] = useState(false);
const [replyDraftById, setReplyDraftById] = useState<Record<string, string>>({});
const refreshNotifications = useCallback(async () => {
setNotificationsLoading(true);
try {
const [items, count] = await Promise.all([
getNotifications('all'),
getUnreadNotificationCount(),
]);
setNotifications(items);
setUnreadCount(count);
} catch {
setNotifications([]);
setUnreadCount(0);
} finally {
setNotificationsLoading(false);
}
}, []);
useEffect(() => {
setTarget(targetFromLocation(location.pathname, location.search));
}, [location.pathname, location.search]);
useEffect(() => {
void refreshNotifications();
}, [refreshNotifications]);
const navigateDigital = useCallback((nextTarget: DigitalNavigationTarget) => {
setTarget(nextTarget);
const params = new URLSearchParams();
@@ -92,6 +146,24 @@ export default function DigitalEmployeeShell() {
}, { replace: false });
}, [location.pathname, navigate]);
const handleNotificationRead = useCallback(async (id: string) => {
await markNotificationRead(id);
await refreshNotifications();
}, [refreshNotifications]);
const handleNotificationDismiss = useCallback(async (id: string) => {
await dismissNotification(id);
await refreshNotifications();
}, [refreshNotifications]);
const handleNotificationReply = useCallback(async (id: string) => {
const content = (replyDraftById[id] ?? '').trim();
if (!content) return;
await replyToNotification(id, content);
setReplyDraftById((current) => ({ ...current, [id]: '' }));
await refreshNotifications();
}, [refreshNotifications, replyDraftById]);
return (
<RoleProvider>
<div className="de-shell">
@@ -118,9 +190,74 @@ export default function DigitalEmployeeShell() {
<button type="button" className="de-header-icon" title="消息">
<Mail size={16} />
</button>
<button type="button" className="de-header-icon" title="通知">
<Bell size={16} />
</button>
<div className="de-notification-anchor">
<button
type="button"
className={`de-header-icon ${notificationsOpen ? 'active' : ''}`}
title="通知"
onClick={() => {
setNotificationsOpen((open) => !open);
void refreshNotifications();
}}
>
<Bell size={16} />
{unreadCount > 0 && <span className="de-notification-count">{unreadCount > 99 ? '99+' : unreadCount}</span>}
</button>
{notificationsOpen && (
<div className="de-notification-popover">
<div className="de-notification-head">
<div>
<strong></strong>
<span>{unreadCount > 0 ? `${unreadCount} 条未读` : '暂无未读'}</span>
</div>
<button type="button" className="de-header-icon de-notification-close" title="关闭" onClick={() => setNotificationsOpen(false)}>
<X size={14} />
</button>
</div>
<div className="de-notification-list">
{notificationsLoading && <div className="de-notification-empty"></div>}
{!notificationsLoading && notifications.length === 0 && <div className="de-notification-empty"></div>}
{!notificationsLoading && notifications.map((notification) => (
<article key={notification.notification_id} className={`de-notification-item de-notification-item--${notification.level}`}>
<div className="de-notification-item-head">
<span className="de-notification-level">{notificationLevelLabel(notification.level)}</span>
<span className="de-notification-time">{notificationTime(notification.created_at)}</span>
</div>
<div className="de-notification-title-row">
<h3>{notification.title}</h3>
<span>{notificationStatusLabel(notification.status)}</span>
</div>
<p>{notification.body}</p>
{notification.reply && <div className="de-notification-reply">{notification.reply}</div>}
{notification.status !== 'resolved' && notification.status !== 'dismissed' && (
<div className="de-notification-actions">
{notification.status === 'unread' && (
<button type="button" className="de-notification-icon-btn" title="标记已读" onClick={() => void handleNotificationRead(notification.notification_id)}>
<Check size={14} />
</button>
)}
<button type="button" className="de-notification-icon-btn" title="忽略" onClick={() => void handleNotificationDismiss(notification.notification_id)}>
<X size={14} />
</button>
</div>
)}
{notification.kind === 'need_input' && notification.status !== 'dismissed' && (
<div className="de-notification-reply-row">
<input
value={replyDraftById[notification.notification_id] ?? ''}
onChange={(event) => setReplyDraftById((current) => ({ ...current, [notification.notification_id]: event.target.value }))}
/>
<button type="button" className="de-notification-icon-btn" title="回复" onClick={() => void handleNotificationReply(notification.notification_id)}>
<MessageSquare size={14} />
</button>
</div>
)}
</article>
))}
</div>
</div>
)}
</div>
<button type="button" className="de-header-icon de-header-close" title="关闭" onClick={closeDigitalWindow}>
<X size={17} />
</button>