feat(message): add message center module (Phase 5)

Implement the complete message center with:
- Database migrations for message_templates, messages, message_subscriptions tables
- erp-message crate with entities, DTOs, services, handlers
- Message CRUD, send, read/unread tracking, soft delete
- Template management with variable interpolation
- Subscription preferences with DND support
- Frontend: messages page, notification panel, unread count badge
- Server integration with module registration and routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-04-11 12:25:05 +08:00
parent 91ecaa3ed7
commit 5ceed71e62
35 changed files with 2252 additions and 15 deletions

View File

@@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { Badge, List, Popover, Button, Empty, Typography, Space } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useMessageStore } from '../stores/message';
const { Text } = Typography;
export default function NotificationPanel() {
const navigate = useNavigate();
const { unreadCount, recentMessages, fetchUnreadCount, fetchRecentMessages, markAsRead } =
useMessageStore();
useEffect(() => {
fetchUnreadCount();
fetchRecentMessages();
// 每 60 秒刷新一次
const interval = setInterval(() => {
fetchUnreadCount();
fetchRecentMessages();
}, 60000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const content = (
<div style={{ width: 360 }}>
{recentMessages.length === 0 ? (
<Empty description="暂无消息" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
dataSource={recentMessages}
renderItem={(item) => (
<List.Item
style={{ padding: '8px 0', cursor: 'pointer' }}
onClick={() => {
if (!item.is_read) {
markAsRead(item.id);
}
}}
>
<List.Item.Meta
title={
<Space>
<Text strong={!item.is_read} ellipsis style={{ maxWidth: 260 }}>
{item.title}
</Text>
{!item.is_read && <span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: '#1677ff' }} />}
</Space>
}
description={
<Text type="secondary" ellipsis style={{ maxWidth: 300 }}>
{item.body}
</Text>
}
/>
</List.Item>
)}
/>
)}
<div style={{ textAlign: 'center', paddingTop: 8, borderTop: '1px solid #f0f0f0' }}>
<Button type="link" onClick={() => navigate('/messages')}>
</Button>
</div>
</div>
);
return (
<Popover content={content} trigger="click" placement="bottomRight">
<Badge count={unreadCount} size="small" offset={[4, -4]}>
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
</Badge>
</Popover>
);
}