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:
@@ -10,6 +10,7 @@ import Users from './pages/Users';
|
||||
import Organizations from './pages/Organizations';
|
||||
import Settings from './pages/Settings';
|
||||
import Workflow from './pages/Workflow';
|
||||
import Messages from './pages/Messages';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useAppStore } from './stores/app';
|
||||
|
||||
@@ -48,6 +49,7 @@ export default function App() {
|
||||
<Route path="/roles" element={<Roles />} />
|
||||
<Route path="/organizations" element={<Organizations />} />
|
||||
<Route path="/workflow" element={<Workflow />} />
|
||||
<Route path="/messages" element={<Messages />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
|
||||
40
apps/web/src/api/messageTemplates.ts
Normal file
40
apps/web/src/api/messageTemplates.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface MessageTemplateInfo {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
channel: string;
|
||||
title_template: string;
|
||||
body_template: string;
|
||||
language: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTemplateRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
channel?: string;
|
||||
title_template: string;
|
||||
body_template: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export async function listTemplates(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MessageTemplateInfo> }>(
|
||||
'/message-templates',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createTemplate(req: CreateTemplateRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: MessageTemplateInfo }>(
|
||||
'/message-templates',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
88
apps/web/src/api/messages.ts
Normal file
88
apps/web/src/api/messages.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface MessageInfo {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
template_id?: string;
|
||||
sender_id?: string;
|
||||
sender_type: string;
|
||||
recipient_id: string;
|
||||
recipient_type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
priority: string;
|
||||
business_type?: string;
|
||||
business_id?: string;
|
||||
is_read: boolean;
|
||||
read_at?: string;
|
||||
is_archived: boolean;
|
||||
status: string;
|
||||
sent_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
title: string;
|
||||
body: string;
|
||||
recipient_id: string;
|
||||
recipient_type?: string;
|
||||
priority?: string;
|
||||
template_id?: string;
|
||||
business_type?: string;
|
||||
business_id?: string;
|
||||
}
|
||||
|
||||
export interface MessageQuery {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
is_read?: boolean;
|
||||
priority?: string;
|
||||
business_type?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export async function listMessages(query: MessageQuery = {}) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MessageInfo> }>(
|
||||
'/messages',
|
||||
{ params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function getUnreadCount() {
|
||||
const { data } = await client.get<{ success: boolean; data: { count: number } }>(
|
||||
'/messages/unread-count',
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function markRead(id: string) {
|
||||
const { data } = await client.put<{ success: boolean }>(
|
||||
`/messages/${id}/read`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function markAllRead() {
|
||||
const { data } = await client.put<{ success: boolean }>(
|
||||
'/messages/read-all',
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteMessage(id: string) {
|
||||
const { data } = await client.delete<{ success: boolean }>(
|
||||
`/messages/${id}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendMessage(req: SendMessageRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: MessageInfo }>(
|
||||
'/messages/send',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
76
apps/web/src/components/NotificationPanel.tsx
Normal file
76
apps/web/src/components/NotificationPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Layout, Menu, theme, Avatar, Space, Dropdown, Button } from 'antd';
|
||||
import NotificationPanel from '../components/NotificationPanel';
|
||||
import {
|
||||
HomeOutlined,
|
||||
UserOutlined,
|
||||
SafetyOutlined,
|
||||
ApartmentOutlined,
|
||||
BellOutlined,
|
||||
SettingOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
PartitionOutlined,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAppStore } from '../stores/app';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
@@ -23,6 +24,7 @@ const menuItems = [
|
||||
{ key: '/roles', icon: <SafetyOutlined />, label: '权限管理' },
|
||||
{ key: '/organizations', icon: <ApartmentOutlined />, label: '组织架构' },
|
||||
{ key: '/workflow', icon: <PartitionOutlined />, label: '工作流' },
|
||||
{ key: '/messages', icon: <MessageOutlined />, label: '消息中心' },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
];
|
||||
|
||||
@@ -31,6 +33,8 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
const { user, logout } = useAuthStore();
|
||||
const { token } = theme.useToken();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const currentPath = location.pathname || '/';
|
||||
|
||||
const userMenuItems = [
|
||||
{
|
||||
@@ -64,7 +68,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
items={menuItems}
|
||||
defaultSelectedKeys={['/']}
|
||||
selectedKeys={[currentPath]}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
/>
|
||||
</Sider>
|
||||
@@ -87,7 +91,7 @@ export default function MainLayout({ children }: { children: React.ReactNode })
|
||||
/>
|
||||
</Space>
|
||||
<Space size="middle">
|
||||
<BellOutlined style={{ fontSize: 18, cursor: 'pointer' }} />
|
||||
<NotificationPanel />
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<Avatar icon={<UserOutlined />} />
|
||||
|
||||
40
apps/web/src/pages/Messages.tsx
Normal file
40
apps/web/src/pages/Messages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
apps/web/src/pages/messages/MessageTemplates.tsx
Normal file
117
apps/web/src/pages/messages/MessageTemplates.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
apps/web/src/pages/messages/NotificationList.tsx
Normal file
166
apps/web/src/pages/messages/NotificationList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
apps/web/src/pages/messages/NotificationPreferences.tsx
Normal file
71
apps/web/src/pages/messages/NotificationPreferences.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/web/src/stores/message.ts
Normal file
47
apps/web/src/stores/message.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { create } from 'zustand';
|
||||
import { getUnreadCount, listMessages, markRead, type MessageInfo } from '../api/messages';
|
||||
|
||||
interface MessageState {
|
||||
unreadCount: number;
|
||||
recentMessages: MessageInfo[];
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
fetchRecentMessages: () => Promise<void>;
|
||||
markAsRead: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useMessageStore = create<MessageState>((set) => ({
|
||||
unreadCount: 0,
|
||||
recentMessages: [],
|
||||
|
||||
fetchUnreadCount: async () => {
|
||||
try {
|
||||
const result = await getUnreadCount();
|
||||
set({ unreadCount: result.count });
|
||||
} catch {
|
||||
// 静默失败,不影响用户体验
|
||||
}
|
||||
},
|
||||
|
||||
fetchRecentMessages: async () => {
|
||||
try {
|
||||
const result = await listMessages({ page: 1, page_size: 5 });
|
||||
set({ recentMessages: result.data });
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
},
|
||||
|
||||
markAsRead: async (id: string) => {
|
||||
try {
|
||||
await markRead(id);
|
||||
set((state) => ({
|
||||
unreadCount: Math.max(0, state.unreadCount - 1),
|
||||
recentMessages: state.recentMessages.map((m) =>
|
||||
m.id === id ? { ...m, is_read: true } : m,
|
||||
),
|
||||
}));
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user