feat(messages): 补齐 PC 消息中心通知与已读操作

This commit is contained in:
yml2213
2026-10-30 14:19:23 +08:00
parent ca2c8fee26
commit 398bf28d7d
3 changed files with 257 additions and 21 deletions
+29
View File
@@ -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 条。
+142 -21
View File
@@ -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>
+86
View File
@@ -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;
}