Files
hms/apps/web/src/components/NotificationPanel.tsx
iven 6d66a392db feat(web): NotificationPanel 增加待办预览区域
- 底部新增"待办事项"区域,显示最近 3 条 pending 行动项
- 角标数字改为 unreadCount + pendingActionCount
- 点击待办项跳转 /health/action-inbox
2026-05-01 16:37:29 +08:00

249 lines
7.4 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Badge, Divider, List, Popover, Button, Empty, Typography } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useMessageStore } from '../stores/message';
import { useThemeMode } from '../hooks/useThemeMode';
import { actionInboxApi, type ActionItem } from '../api/health/actionInbox';
const { Text } = Typography;
export default function NotificationPanel() {
const navigate = useNavigate();
const unreadCount = useMessageStore((s) => s.unreadCount);
const recentMessages = useMessageStore((s) => s.recentMessages);
const markAsRead = useMessageStore((s) => s.markAsRead);
const isDark = useThemeMode();
const initializedRef = useRef(false);
const [pendingActions, setPendingActions] = useState<ActionItem[]>([]);
const fetchPendingActions = useCallback(async () => {
try {
const resp = await actionInboxApi.list({ status: 'pending', page_size: 3 });
setPendingActions(resp.data);
} catch {
// 静默失败,不影响通知面板
}
}, []);
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
const { fetchUnreadCount, fetchRecentMessages, connectSSE } = useMessageStore.getState();
fetchUnreadCount();
fetchRecentMessages();
fetchPendingActions();
const disconnectSSE = connectSSE();
const interval = setInterval(() => {
fetchUnreadCount();
fetchRecentMessages();
fetchPendingActions();
}, 60000);
return () => {
clearInterval(interval);
disconnectSSE();
initializedRef.current = false;
};
}, [fetchPendingActions]);
const totalBadge = unreadCount + pendingActions.length;
const content = (
<div style={{ width: 360 }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
padding: '4px 0',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}></span>
{unreadCount > 0 && (
<Button
type="text"
size="small"
style={{ fontSize: 12, color: '#2563eb' }}
onClick={() => navigate('/messages')}
>
</Button>
)}
</div>
{recentMessages.length === 0 ? (
<Empty
description="暂无消息"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: '24px 0' }}
/>
) : (
<List
dataSource={recentMessages.slice(0, 5)}
renderItem={(item) => (
<List.Item
style={{
padding: '10px 12px',
margin: '2px 0',
borderRadius: 8,
cursor: 'pointer',
transition: 'background 0.15s ease',
border: 'none',
background: !item.is_read ? (isDark ? '#0f172a' : '#eff6ff') : 'transparent',
}}
onClick={() => {
if (!item.is_read) {
markAsRead(item.id);
}
navigate('/messages');
}}
onMouseEnter={(e) => {
if (item.is_read) {
e.currentTarget.style.background = isDark ? '#0f172a' : '#f1f5f9';
}
}}
onMouseLeave={(e) => {
if (item.is_read) {
e.currentTarget.style.background = 'transparent';
}
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text
strong={!item.is_read}
ellipsis
style={{ maxWidth: 260, fontSize: 13 }}
>
{item.title}
</Text>
{!item.is_read && (
<span style={{
display: 'inline-block',
width: 6,
height: 6,
borderRadius: '50%',
background: '#2563eb',
flexShrink: 0,
}} />
)}
</div>
<Text
type="secondary"
ellipsis
style={{ maxWidth: 300, fontSize: 12, display: 'block', marginTop: 2 }}
>
{item.body}
</Text>
</div>
</List.Item>
)}
/>
)}
{recentMessages.length > 0 && (
<div style={{
textAlign: 'center',
paddingTop: 8,
marginTop: 4,
borderTop: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}>
<Button
type="text"
onClick={() => navigate('/messages')}
style={{ fontSize: 13, color: '#2563eb', fontWeight: 500 }}
>
</Button>
</div>
)}
{/* 待办预览区域 */}
<Divider style={{ margin: '8px 0' }} />
<div style={{ padding: '4px 0' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
}}>
<Text strong style={{ fontSize: 13 }}></Text>
<Button
type="link"
size="small"
onClick={() => navigate('/health/action-inbox')}
style={{ fontSize: 12, padding: 0 }}
>
</Button>
</div>
{pendingActions.length === 0 ? (
<Text type="secondary" style={{ fontSize: 12, display: 'block', textAlign: 'center', padding: '8px 0' }}>
</Text>
) : (
<List
size="small"
dataSource={pendingActions}
renderItem={(item) => (
<List.Item
style={{ padding: '4px 0', cursor: 'pointer', border: 'none' }}
onClick={() => navigate('/health/action-inbox')}
>
<List.Item.Meta
title={<Text style={{ fontSize: 12 }}>{item.title}</Text>}
description={
<Text type="secondary" style={{ fontSize: 11 }}>
{item.patient_name}
</Text>
}
/>
</List.Item>
)}
/>
)}
</div>
</div>
);
return (
<Popover
content={content}
trigger="click"
placement="bottomRight"
overlayStyle={{ padding: 0 }}
>
<div
style={{
width: 36,
height: 36,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.15s ease',
position: 'relative',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark ? '#0f172a' : '#f8fafc';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<Badge count={totalBadge} size="small" offset={[4, -4]}>
<BellOutlined style={{
fontSize: 16,
color: isDark ? '#94a3b8' : '#475569',
}} />
</Badge>
</div>
</Popover>
);
}