feat(user): 新增公告中心与通知页

This commit is contained in:
yml2213
2026-10-30 17:15:02 +08:00
parent e309fddd18
commit 89e6a13fa7
7 changed files with 356 additions and 0 deletions
+18
View File
@@ -60,6 +60,21 @@ const PaymentAccountsPage = lazy(() =>
const WithdrawalPage = lazy(() =>
import('@/features/wallet/WithdrawalPage').then(module => ({ default: module.WithdrawalPage }))
)
const AnnouncementsPage = lazy(() =>
import('@/features/announcements/AnnouncementsPage').then(module => ({
default: module.AnnouncementsPage,
}))
)
const AnnouncementDetailPage = lazy(() =>
import('@/features/announcements/AnnouncementDetailPage').then(module => ({
default: module.AnnouncementDetailPage,
}))
)
const NotificationsPage = lazy(() =>
import('@/features/notifications/NotificationsPage').then(module => ({
default: module.NotificationsPage,
}))
)
const MessagesPage = lazy(() =>
import('@/features/messages/MessagesPage').then(module => ({ default: module.MessagesPage }))
)
@@ -377,6 +392,9 @@ export function AppRouter() {
<Route path="wallet" element={<WalletPage />} />
<Route path="wallet/payment-accounts" element={<PaymentAccountsPage />} />
<Route path="wallet/withdrawal" element={<WithdrawalPage />} />
<Route path="announcements" element={<AnnouncementsPage />} />
<Route path="announcements/:id" element={<AnnouncementDetailPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="messages" element={<MessagesPage />} />
<Route path="messages/:id" element={<ChatPage />} />
<Route path="seller/listings" element={<SellerListingsPage />} />
@@ -0,0 +1,50 @@
import { Alert, Button, Card, Skeleton, Space, Tag, Typography } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import { useNavigate, useParams } from 'react-router-dom'
import { useAnnouncement } from '@/features/announcements/api'
import { formatDateTime, formatText } from '@/shared/utils/format'
export function AnnouncementDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const query = useAnnouncement(id)
const item = query.data
return (
<div className="wallet-page">
<Space style={{ marginBottom: 8 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/announcements')}>
返回公告列表
</Button>
</Space>
{query.isError ? (
<Alert type="error" showIcon message="公告加载失败" description="公告可能已下线或不存在。" />
) : null}
<Card className="wallet-ledger-card">
{query.isPending ? (
<Skeleton active paragraph={{ rows: 8 }} />
) : !item ? null : (
<>
<Typography.Title level={2} className="brand-title" style={{ marginTop: 0 }}>
{item.title}
</Typography.Title>
<Space size={8} wrap style={{ marginBottom: 16 }}>
{item.is_pinned ? <Tag color="red">置顶</Tag> : null}
{item.is_important ? <Tag color="orange">重要</Tag> : null}
<Tag bordered={false}>{formatText(item.category)}</Tag>
<Typography.Text type="secondary">
{formatDateTime(item.published_at || item.created_at)} · 阅读 {item.view_count}
</Typography.Text>
</Space>
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', lineHeight: 1.9 }}>
{item.content}
</Typography.Paragraph>
</>
)}
</Card>
</div>
)
}
@@ -0,0 +1,95 @@
import { useState } from 'react'
import { Alert, Card, Pagination, Segmented, Space, Tag, Typography } from 'antd'
import { Link } from 'react-router-dom'
import { useAnnouncements } from '@/features/announcements/api'
import type { Announcement } from '@/features/announcements/types'
import { formatDateTime, formatText } from '@/shared/utils/format'
const categoryOptions = [
{ value: '', label: '全部' },
{ value: 'system', label: '系统公告' },
{ value: 'order', label: '订单规则' },
{ value: 'activity', label: '活动' },
]
export function AnnouncementsPage() {
const [category, setCategory] = useState('')
const [page, setPage] = useState(1)
const query = useAnnouncements(page, 10, category)
const items = query.data?.items ?? []
return (
<div className="wallet-page">
<section className="wallet-hero wallet-hero--compact">
<div>
<Typography.Text className="wallet-eyebrow">JIEYOU · 公告</Typography.Text>
<Typography.Title level={1} className="brand-title wallet-title">
公告中心
</Typography.Title>
<Typography.Paragraph className="wallet-desc">
平台规则、重要提醒与活动信息;置顶与重要公告会优先展示。
</Typography.Paragraph>
</div>
<div className="wallet-hero__action">
<span>共</span>
<strong>{query.data?.total ?? 0}</strong>
<span>条公告</span>
</div>
</section>
<Segmented
value={category}
options={categoryOptions}
onChange={value => {
setCategory(String(value))
setPage(1)
}}
/>
{query.isError ? (
<Alert type="error" showIcon message="公告加载失败" />
) : null}
<div className="admin-stack" style={{ gap: 12 }}>
{items.length === 0 && !query.isPending ? (
<Card>
<Typography.Text type="secondary">暂无公告</Typography.Text>
</Card>
) : null}
{items.map((item: Announcement) => (
<Card key={item.id} className="wallet-ledger-card">
<Link to={`/announcements/${item.id}`}>
<Space size={8} wrap style={{ marginBottom: 6 }}>
{item.is_pinned ? <Tag color="red">置顶</Tag> : null}
{item.is_important ? <Tag color="orange">重要</Tag> : null}
<Tag bordered={false}>{formatText(item.category)}</Tag>
<Typography.Text strong style={{ fontSize: 16 }}>
{item.title}
</Typography.Text>
</Space>
<Typography.Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ marginBottom: 4 }}>
{item.content}
</Typography.Paragraph>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDateTime(item.published_at || item.created_at)} · 阅读 {item.view_count}
</Typography.Text>
</Link>
</Card>
))}
</div>
{(query.data?.total ?? 0) > 10 ? (
<Pagination
current={page}
pageSize={10}
total={query.data?.total ?? 0}
showSizeChanger={false}
onChange={next => setPage(next)}
style={{ textAlign: 'center' }}
/>
) : null}
</div>
)
}
@@ -0,0 +1,38 @@
import { useQuery } from '@tanstack/react-query'
import { apiClient, unwrapData } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/api/types'
import type { Announcement, AnnouncementPage } from '@/features/announcements/types'
const PAGE_SIZE = 20
export function fetchAnnouncements(page = 1, pageSize = PAGE_SIZE, category = '') {
return unwrapData<Partial<AnnouncementPage>>(
apiClient.get<ApiResponse<Partial<AnnouncementPage>>>('/announcements', {
params: { page, page_size: pageSize, category: category || undefined },
})
).then(data => ({
items: (data.items ?? []) as Announcement[],
total: data.total ?? 0,
page: data.page ?? page,
page_size: data.page_size ?? pageSize,
}))
}
export function useAnnouncements(page: number, pageSize = PAGE_SIZE, category = '') {
return useQuery({
queryKey: ['announcements', page, pageSize, category],
queryFn: () => fetchAnnouncements(page, pageSize, category),
})
}
export function useAnnouncement(id?: string) {
return useQuery({
queryKey: ['announcement', id],
queryFn: () =>
unwrapData<Announcement>(
apiClient.get<ApiResponse<Announcement>>(`/announcements/${id}`)
),
enabled: Boolean(id),
})
}
@@ -0,0 +1,24 @@
export interface Announcement {
id: number
distribution_site_id: number
distribution_site_name?: string
title: string
content: string
category: string
priority: number
is_pinned: boolean
is_important: boolean
is_popup: boolean
view_count: number
status: string
published_at?: string | null
created_at: string
updated_at: string
}
export interface AnnouncementPage {
items: Announcement[]
total: number
page: number
page_size: number
}
@@ -0,0 +1,120 @@
import { useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Alert, Button, Card, Empty, Space, Tag, Typography } from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import {
markAllNotificationsRead,
markNotificationRead,
useNotifications,
} from '@/features/messages/api'
import { useSessionStore } from '@/stores/session'
import { formatDateTime } from '@/shared/utils/format'
export function NotificationsPage() {
const queryClient = useQueryClient()
const user = useSessionStore(state => state.user)
const [page, setPage] = useState(1)
const notificationsQuery = useNotifications(Boolean(user))
const notifications = notificationsQuery.data?.pages.flatMap(item => item.items) ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['notifications'] })
return (
<div className="wallet-page">
<section className="wallet-hero wallet-hero--compact">
<div>
<Typography.Text className="wallet-eyebrow">JIEYOU · 消息</Typography.Text>
<Typography.Title level={1} className="brand-title wallet-title">
我的通知
</Typography.Title>
<Typography.Paragraph className="wallet-desc">
订单、资金与系统通知;未读通知会在消息入口展示红点。
</Typography.Paragraph>
</div>
<div className="wallet-hero__action">
<span>未读</span>
<strong>{notifications.filter(item => !item.read_at).length}</strong>
<Space size={8} wrap>
<Button
icon={<ReloadOutlined />}
loading={notificationsQuery.isFetching}
onClick={() => void notificationsQuery.refetch()}
>
刷新
</Button>
<Button
onClick={async () => {
await markAllNotificationsRead()
await invalidate()
}}
>
全部已读
</Button>
</Space>
</div>
</section>
{notificationsQuery.isError ? (
<Alert type="error" showIcon message="通知加载失败" />
) : null}
<div className="admin-stack" style={{ gap: 10 }}>
{notifications.length === 0 && !notificationsQuery.isPending ? (
<Card>
<Empty description="还没有通知" />
</Card>
) : null}
{notifications.map(item => (
<Card key={item.id} className="wallet-ledger-card">
<Space size={8} wrap style={{ marginBottom: 4 }}>
<Typography.Text strong>{item.title}</Typography.Text>
{item.read_at ? (
<Tag bordered={false}>已读</Tag>
) : (
<Tag bordered={false} color="red">
未读
</Tag>
)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDateTime(item.created_at)}
</Typography.Text>
</Space>
<Typography.Paragraph style={{ marginBottom: 8, whiteSpace: 'pre-wrap' }}>
{item.content}
</Typography.Paragraph>
{item.read_at ? null : (
<Button
type="link"
size="small"
onClick={async () => {
await markNotificationRead(item.id)
await invalidate()
}}
>
标为已读
</Button>
)}
</Card>
))}
</div>
{notificationsQuery.hasNextPage ? (
<Button
block
loading={notificationsQuery.isFetchingNextPage}
onClick={() => {
setPage(current => current + 1)
void notificationsQuery.fetchNextPage()
}}
>
加载更多
</Button>
) : null}
<Typography.Text type="secondary" style={{ display: 'none' }}>
{page}
</Typography.Text>
</div>
)
}
+11
View File
@@ -32,6 +32,7 @@ export function StorefrontLayout() {
</Badge>
</Link>
<Link to="/seller/listings">卖家中心</Link>
<Link to="/announcements">公告</Link>
<a href="/#process">流程</a>
<a href="/#rules">规则</a>
</nav>
@@ -52,6 +53,16 @@ export function StorefrontLayout() {
label: '我的消息',
onClick: () => navigate('/messages'),
},
{
key: 'notifications',
label: '我的通知',
onClick: () => navigate('/notifications'),
},
{
key: 'announcements',
label: '公告中心',
onClick: () => navigate('/announcements'),
},
{
key: 'wallet',
label: '我的钱包',