feat(user): 新增公告中心与通知页
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: '我的钱包',
|
||||
|
||||
Reference in New Issue
Block a user