吸收数字员工通知投影
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user