diff --git a/docs/Phase2-PC消息中心通知验收.md b/docs/Phase2-PC消息中心通知验收.md new file mode 100644 index 0000000..d9afa95 --- /dev/null +++ b/docs/Phase2-PC消息中心通知验收.md @@ -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 条。 diff --git a/frontend/src/features/messages/MessagesPage.tsx b/frontend/src/features/messages/MessagesPage.tsx index 93801b2..9dd3571 100644 --- a/frontend/src/features/messages/MessagesPage.tsx +++ b/frontend/src/features/messages/MessagesPage.tsx @@ -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 ( + + ) +} + 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() { + setTab(key as 'chats' | 'notices')} + className="pc-messages-tabs" + items={[ + { key: 'chats', label: `会话${items.length ? ` · ${items.length}` : ''}` }, + { + key: 'notices', + label: `通知${unreadNotices ? ` · ${unreadNotices}` : ''}`, + }, + ]} + /> +
{items.length} 个 : null} + title={tab === 'chats' ? '全部会话' : '系统通知'} + extra={ + tab === 'chats' ? ( + items.length ? ( + {items.length} 个 + ) : null + ) : unreadNotices > 0 ? ( + + ) : ( + 全部已读 + ) + } className="pc-messages-card" > - {chats.isPending ? ( + {tab === 'chats' ? ( + chats.isPending ? ( + + ) : chats.isError ? ( + void chats.refetch()}> + 重试 + + } + /> + ) : items.length === 0 ? ( + + + + ) : ( +
+ {items.map(item => ( + + ))} + {chats.hasNextPage ? ( + + ) : null} +
+ ) + ) : notices.isPending ? ( - ) : chats.isError ? ( + ) : notices.isError ? ( void chats.refetch()}> + } /> - ) : items.length === 0 ? ( - - - + ) : noticeItems.length === 0 ? ( + ) : ( -
- {items.map(item => ( - +
+ {noticeItems.map(notice => ( + void readNotice(id)} + /> ))} - {chats.hasNextPage ? ( + {notices.hasNextPage ? ( ) : null}
diff --git a/frontend/src/styles/pc-messages.css b/frontend/src/styles/pc-messages.css index 1fda919..d734e13 100644 --- a/frontend/src/styles/pc-messages.css +++ b/frontend/src/styles/pc-messages.css @@ -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; +}