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,40 @@
import { useState } from 'react';
import { Tabs } from 'antd';
import NotificationList from './messages/NotificationList';
import MessageTemplates from './messages/MessageTemplates';
import NotificationPreferences from './messages/NotificationPreferences';
export default function Messages() {
const [activeKey, setActiveKey] = useState('all');
return (
<div>
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
items={[
{
key: 'all',
label: '全部消息',
children: <NotificationList />,
},
{
key: 'unread',
label: '未读消息',
children: <NotificationList queryFilter={{ is_read: false }} />,
},
{
key: 'templates',
label: '消息模板',
children: <MessageTemplates />,
},
{
key: 'preferences',
label: '通知设置',
children: <NotificationPreferences />,
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { Table, Button, Modal, Form, Input, Select, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { listTemplates, createTemplate, type MessageTemplateInfo } from '../../api/messageTemplates';
export default function MessageTemplates() {
const [data, setData] = useState<MessageTemplateInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const fetchData = async (p = page) => {
setLoading(true);
try {
const result = await listTemplates(p, 20);
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载模板列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCreate = async () => {
try {
const values = await form.validateFields();
await createTemplate(values);
message.success('模板创建成功');
setModalOpen(false);
form.resetFields();
fetchData();
} catch {
message.error('创建失败');
}
};
const columns: ColumnsType<MessageTemplateInfo> = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '编码', dataIndex: 'code', key: 'code' },
{
title: '通道',
dataIndex: 'channel',
key: 'channel',
render: (c: string) => {
const map: Record<string, string> = { in_app: '站内', email: '邮件', sms: '短信', wechat: '微信' };
return map[c] || c;
},
},
{ title: '标题模板', dataIndex: 'title_template', key: 'title_template', ellipsis: true },
{ title: '语言', dataIndex: 'language', key: 'language', width: 80 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
];
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button type="primary" onClick={() => setModalOpen(true)}></Button>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => { setPage(p); fetchData(p); },
}}
/>
<Modal
title="新建消息模板"
open={modalOpen}
onOk={handleCreate}
onCancel={() => { setModalOpen(false); form.resetFields(); }}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请输入编码' }]}>
<Input />
</Form.Item>
<Form.Item name="channel" label="通道" initialValue="in_app">
<Select options={[
{ value: 'in_app', label: '站内' },
{ value: 'email', label: '邮件' },
{ value: 'sms', label: '短信' },
{ value: 'wechat', label: '微信' },
]} />
</Form.Item>
<Form.Item name="title_template" label="标题模板" rules={[{ required: true, message: '请输入标题模板' }]}>
<Input placeholder="支持 {{variable}} 变量插值" />
</Form.Item>
<Form.Item name="body_template" label="内容模板" rules={[{ required: true, message: '请输入内容模板' }]}>
<Input.TextArea rows={4} placeholder="支持 {{variable}} 变量插值" />
</Form.Item>
<Form.Item name="language" label="语言" initialValue="zh-CN">
<Select options={[
{ value: 'zh-CN', label: '中文' },
{ value: 'en-US', label: '英文' },
]} />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { useEffect, useState } from 'react';
import { Table, Button, Tag, Space, Modal, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { listMessages, markRead, markAllRead, deleteMessage, type MessageInfo, type MessageQuery } from '../../api/messages';
const { Paragraph } = Typography;
interface Props {
queryFilter?: MessageQuery;
}
export default function NotificationList({ queryFilter }: Props) {
const [data, setData] = useState<MessageInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const fetchData = async (p = page) => {
setLoading(true);
try {
const result = await listMessages({ page: p, page_size: 20, ...queryFilter });
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载消息列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData(1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryFilter]);
const handleMarkRead = async (id: string) => {
try {
await markRead(id);
fetchData();
} catch {
message.error('操作失败');
}
};
const handleMarkAllRead = async () => {
try {
await markAllRead();
fetchData();
message.success('已全部标记为已读');
} catch {
message.error('操作失败');
}
};
const handleDelete = async (id: string) => {
try {
await deleteMessage(id);
fetchData();
message.success('已删除');
} catch {
message.error('删除失败');
}
};
const showDetail = (record: MessageInfo) => {
Modal.info({
title: record.title,
width: 520,
content: (
<div>
<Paragraph>{record.body}</Paragraph>
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
{record.created_at}
</div>
</div>
),
});
if (!record.is_read) {
handleMarkRead(record.id);
}
};
const priorityColor: Record<string, string> = {
urgent: 'red',
important: 'orange',
normal: 'blue',
};
const columns: ColumnsType<MessageInfo> = [
{
title: '标题',
dataIndex: 'title',
key: 'title',
render: (text: string, record) => (
<span style={{ fontWeight: record.is_read ? 400 : 700, cursor: 'pointer' }} onClick={() => showDetail(record)}>
{text}
</span>
),
},
{
title: '优先级',
dataIndex: 'priority',
key: 'priority',
width: 100,
render: (p: string) => <Tag color={priorityColor[p] || 'blue'}>{p}</Tag>,
},
{
title: '发送者',
dataIndex: 'sender_type',
key: 'sender_type',
width: 80,
render: (s: string) => (s === 'system' ? '系统' : '用户'),
},
{
title: '状态',
dataIndex: 'is_read',
key: 'is_read',
width: 80,
render: (r: boolean) => (r ? <Tag></Tag> : <Tag color="processing"></Tag>),
},
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
},
{
title: '操作',
key: 'actions',
width: 120,
render: (_: unknown, record) => (
<Space>
{!record.is_read && (
<Button type="link" size="small" onClick={() => handleMarkRead(record.id)}>
</Button>
)}
<Button type="link" size="small" danger onClick={() => handleDelete(record.id)}>
</Button>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<span> {total} </span>
<Button onClick={handleMarkAllRead}></Button>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => { setPage(p); fetchData(p); },
}}
/>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { Form, Switch, TimePicker, Button, Card, message } from 'antd';
import type { Dayjs } from 'dayjs';
interface PreferencesData {
dnd_enabled: boolean;
dnd_start?: string;
dnd_end?: string;
}
export default function NotificationPreferences() {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [dndEnabled, setDndEnabled] = useState(false);
useEffect(() => {
// 加载当前偏好设置
// 暂时使用默认值,后续连接 API
form.setFieldsValue({
dnd_enabled: false,
});
}, [form]);
const handleSave = async () => {
setLoading(true);
try {
const values = await form.validateFields();
const req: PreferencesData = {
dnd_enabled: values.dnd_enabled || false,
dnd_start: values.dnd_range?.[0]?.format('HH:mm'),
dnd_end: values.dnd_range?.[1]?.format('HH:mm'),
};
// 调用 API 更新偏好
const client = await import('../../api/client').then(m => m.default);
await client.put('/message-subscriptions', {
dnd_enabled: req.dnd_enabled,
dnd_start: req.dnd_start,
dnd_end: req.dnd_end,
});
message.success('偏好设置已保存');
} catch {
message.error('保存失败');
} finally {
setLoading(false);
}
};
return (
<Card title="通知偏好设置" style={{ maxWidth: 600 }}>
<Form form={form} layout="vertical">
<Form.Item name="dnd_enabled" label="免打扰模式" valuePropName="checked">
<Switch onChange={setDndEnabled} />
</Form.Item>
{dndEnabled && (
<Form.Item name="dnd_range" label="免打扰时段">
<TimePicker.RangePicker format="HH:mm" />
</Form.Item>
)}
<Form.Item>
<Button type="primary" onClick={handleSave} loading={loading}>
</Button>
</Form.Item>
</Form>
</Card>
);
}