feat(messages): 补齐 PC 消息中心通知与已读操作
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# Phase 2 PC 消息中心通知验收
|
||||
|
||||
> 日期:2026-10-30
|
||||
> 范围:PC 消息中心增加「通知」Tab,与移动端消息中心对齐。
|
||||
|
||||
## 1. 交付内容
|
||||
|
||||
- `frontend/src/features/messages/MessagesPage.tsx`
|
||||
- 新增「会话 / 通知」Tabs。
|
||||
- 通知列表展示标题、正文、相对时间与未读点。
|
||||
- 点击单条通知调用 `POST /notifications/:id/read` 并刷新列表。
|
||||
- 顶栏支持「全部已读」,调用 `PUT /notifications/read-all`。
|
||||
- 未读通知数量展示在 Tab 与页面顶部。
|
||||
- `frontend/src/styles/pc-messages.css`
|
||||
- 新增 `.pc-messages-tabs`、`.pc-notice-list`、`.pc-notice-row*` 样式。
|
||||
- 未读通知使用朱砂色圆点与浅色光晕,保持现有品牌语言。
|
||||
|
||||
## 2. 验证记录
|
||||
|
||||
| 检查 | 结果 |
|
||||
|---|---|
|
||||
| `npx tsc --noEmit` | 见提交时结果 |
|
||||
| `npm run build` | 见提交时结果 |
|
||||
| `GET /api/notifications` | 现有接口,移动端已在用 |
|
||||
|
||||
## 3. 说明
|
||||
|
||||
- 通知点击目前只标记已读,不自动跳转业务详情;后续可按 `biz_type/biz_id` 映射订单、申诉或公告。
|
||||
- 通知分页沿用 React Query `useInfiniteQuery`,每页 20 条。
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
@@ -8,15 +9,23 @@ import {
|
||||
Empty,
|
||||
Skeleton,
|
||||
Space,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { ArrowRightOutlined, CustomerServiceOutlined, MessageOutlined } from '@ant-design/icons'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { BrandLogo } from '@/components/BrandLogo'
|
||||
import { ensureSupportConversation, useConversations } from '@/features/messages/api'
|
||||
import type { ChatConversation } from '@/features/messages/types'
|
||||
import {
|
||||
ensureSupportConversation,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
useConversations,
|
||||
useNotifications,
|
||||
} from '@/features/messages/api'
|
||||
import type { AppNotification, ChatConversation } from '@/features/messages/types'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatRelativeTime } from '@/shared/utils/format'
|
||||
|
||||
@@ -58,14 +67,65 @@ function ConversationRow({ item }: { item: ChatConversation }) {
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationRow({
|
||||
notice,
|
||||
onRead,
|
||||
}: {
|
||||
notice: AppNotification
|
||||
onRead: (id: number) => void
|
||||
}) {
|
||||
const unread = !notice.read_at
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`pc-notice-row${unread ? ' pc-notice-row--unread' : ''}`}
|
||||
onClick={() => onRead(notice.id)}
|
||||
>
|
||||
<span className="pc-notice-row__dot" aria-hidden />
|
||||
<span className="pc-notice-row__body">
|
||||
<span className="pc-notice-row__top">
|
||||
<strong>{notice.title}</strong>
|
||||
<small>{formatRelativeTime(notice.created_at)}</small>
|
||||
</span>
|
||||
<span className="pc-notice-row__content">{notice.content}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessagesPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { message } = App.useApp()
|
||||
const token = useSessionStore(state => state.token)
|
||||
const hydrated = useSessionStore(state => state.hydrated)
|
||||
const [openingSupport, setOpeningSupport] = useState(false)
|
||||
const [tab, setTab] = useState<'chats' | 'notices'>('chats')
|
||||
const chats = useConversations(Boolean(token))
|
||||
const notices = useNotifications(Boolean(token))
|
||||
const items = chats.data?.pages.flatMap(page => page.items) ?? []
|
||||
const noticeItems = notices.data?.pages.flatMap(page => page.items) ?? []
|
||||
const unreadTotal = items.reduce((sum, item) => sum + item.unread_count, 0)
|
||||
const unreadNotices = noticeItems.filter(item => !item.read_at).length
|
||||
|
||||
const readNotice = async (id: number) => {
|
||||
try {
|
||||
await markNotificationRead(id)
|
||||
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '标记已读失败')
|
||||
}
|
||||
}
|
||||
|
||||
const readAllNotices = async () => {
|
||||
try {
|
||||
await markAllNotificationsRead()
|
||||
await queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
||||
message.success('通知已全部标记为已读')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '操作失败,请稍后再试')
|
||||
}
|
||||
}
|
||||
|
||||
const openSupport = async () => {
|
||||
setOpeningSupport(true)
|
||||
@@ -132,44 +192,105 @@ export function MessagesPage() {
|
||||
</Space>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
activeKey={tab}
|
||||
onChange={key => setTab(key as 'chats' | 'notices')}
|
||||
className="pc-messages-tabs"
|
||||
items={[
|
||||
{ key: 'chats', label: `会话${items.length ? ` · ${items.length}` : ''}` },
|
||||
{
|
||||
key: 'notices',
|
||||
label: `通知${unreadNotices ? ` · ${unreadNotices}` : ''}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="pc-messages-grid">
|
||||
<main>
|
||||
<Card
|
||||
title="全部会话"
|
||||
extra={items.length ? <Typography.Text type="secondary">{items.length} 个</Typography.Text> : null}
|
||||
title={tab === 'chats' ? '全部会话' : '系统通知'}
|
||||
extra={
|
||||
tab === 'chats' ? (
|
||||
items.length ? (
|
||||
<Typography.Text type="secondary">{items.length} 个</Typography.Text>
|
||||
) : null
|
||||
) : unreadNotices > 0 ? (
|
||||
<Button size="small" onClick={() => void readAllNotices()}>
|
||||
全部已读
|
||||
</Button>
|
||||
) : (
|
||||
<Tag>全部已读</Tag>
|
||||
)
|
||||
}
|
||||
className="pc-messages-card"
|
||||
>
|
||||
{chats.isPending ? (
|
||||
{tab === 'chats' ? (
|
||||
chats.isPending ? (
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
) : chats.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="会话没有加载出来"
|
||||
action={
|
||||
<Button size="small" onClick={() => void chats.refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<Empty description="还没有会话">
|
||||
<Button type="primary" loading={openingSupport} onClick={() => void openSupport()}>
|
||||
联系平台客服
|
||||
</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="pc-chat-list">
|
||||
{items.map(item => (
|
||||
<ConversationRow item={item} key={item.id} />
|
||||
))}
|
||||
{chats.hasNextPage ? (
|
||||
<Button
|
||||
block
|
||||
loading={chats.isFetchingNextPage}
|
||||
onClick={() => void chats.fetchNextPage()}
|
||||
>
|
||||
加载更多会话
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
) : notices.isPending ? (
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
) : chats.isError ? (
|
||||
) : notices.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="会话没有加载出来"
|
||||
message="通知没有加载出来"
|
||||
action={
|
||||
<Button size="small" onClick={() => void chats.refetch()}>
|
||||
<Button size="small" onClick={() => void notices.refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : items.length === 0 ? (
|
||||
<Empty description="还没有会话">
|
||||
<Button type="primary" loading={openingSupport} onClick={() => void openSupport()}>
|
||||
联系平台客服
|
||||
</Button>
|
||||
</Empty>
|
||||
) : noticeItems.length === 0 ? (
|
||||
<Empty description="暂时没有通知" />
|
||||
) : (
|
||||
<div className="pc-chat-list">
|
||||
{items.map(item => (
|
||||
<ConversationRow item={item} key={item.id} />
|
||||
<div className="pc-notice-list">
|
||||
{noticeItems.map(notice => (
|
||||
<NotificationRow
|
||||
notice={notice}
|
||||
key={notice.id}
|
||||
onRead={id => void readNotice(id)}
|
||||
/>
|
||||
))}
|
||||
{chats.hasNextPage ? (
|
||||
{notices.hasNextPage ? (
|
||||
<Button
|
||||
block
|
||||
loading={chats.isFetchingNextPage}
|
||||
onClick={() => void chats.fetchNextPage()}
|
||||
loading={notices.isFetchingNextPage}
|
||||
onClick={() => void notices.fetchNextPage()}
|
||||
>
|
||||
加载更多会话
|
||||
加载更多通知
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -424,3 +424,89 @@
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.pc-messages-tabs {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pc-messages-tabs .ant-tabs-nav {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pc-notice-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pc-notice-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 16px 20px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.pc-notice-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.pc-notice-row:hover {
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.pc-notice-row__dot {
|
||||
flex: 0 0 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.pc-notice-row--unread .pc-notice-row__dot {
|
||||
background: var(--brand-seal);
|
||||
box-shadow: 0 0 0 3px rgba(166, 67, 50, 0.12);
|
||||
}
|
||||
|
||||
.pc-notice-row__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pc-notice-row__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pc-notice-row__top strong {
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pc-notice-row__top small {
|
||||
flex: 0 0 auto;
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pc-notice-row__content {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin-top: 5px;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user