diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index a5329ce..22cf81b 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -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() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/announcements/AnnouncementDetailPage.tsx b/frontend/src/features/announcements/AnnouncementDetailPage.tsx new file mode 100644 index 0000000..01290ee --- /dev/null +++ b/frontend/src/features/announcements/AnnouncementDetailPage.tsx @@ -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 ( +
+ + + + + {query.isError ? ( + + ) : null} + + + {query.isPending ? ( + + ) : !item ? null : ( + <> + + {item.title} + + + {item.is_pinned ? 置顶 : null} + {item.is_important ? 重要 : null} + {formatText(item.category)} + + {formatDateTime(item.published_at || item.created_at)} · 阅读 {item.view_count} + + + + {item.content} + + + )} + +
+ ) +} diff --git a/frontend/src/features/announcements/AnnouncementsPage.tsx b/frontend/src/features/announcements/AnnouncementsPage.tsx new file mode 100644 index 0000000..fc1a159 --- /dev/null +++ b/frontend/src/features/announcements/AnnouncementsPage.tsx @@ -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 ( +
+
+
+ JIEYOU · 公告 + + 公告中心 + + + 平台规则、重要提醒与活动信息;置顶与重要公告会优先展示。 + +
+
+ 共 + {query.data?.total ?? 0} + 条公告 +
+
+ + { + setCategory(String(value)) + setPage(1) + }} + /> + + {query.isError ? ( + + ) : null} + +
+ {items.length === 0 && !query.isPending ? ( + + 暂无公告 + + ) : null} + + {items.map((item: Announcement) => ( + + + + {item.is_pinned ? 置顶 : null} + {item.is_important ? 重要 : null} + {formatText(item.category)} + + {item.title} + + + + {item.content} + + + {formatDateTime(item.published_at || item.created_at)} · 阅读 {item.view_count} + + + + ))} +
+ + {(query.data?.total ?? 0) > 10 ? ( + setPage(next)} + style={{ textAlign: 'center' }} + /> + ) : null} +
+ ) +} diff --git a/frontend/src/features/announcements/api.ts b/frontend/src/features/announcements/api.ts new file mode 100644 index 0000000..c454656 --- /dev/null +++ b/frontend/src/features/announcements/api.ts @@ -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>( + apiClient.get>>('/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( + apiClient.get>(`/announcements/${id}`) + ), + enabled: Boolean(id), + }) +} diff --git a/frontend/src/features/announcements/types.ts b/frontend/src/features/announcements/types.ts new file mode 100644 index 0000000..22d74d4 --- /dev/null +++ b/frontend/src/features/announcements/types.ts @@ -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 +} diff --git a/frontend/src/features/notifications/NotificationsPage.tsx b/frontend/src/features/notifications/NotificationsPage.tsx new file mode 100644 index 0000000..d9f8951 --- /dev/null +++ b/frontend/src/features/notifications/NotificationsPage.tsx @@ -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 ( +
+
+
+ JIEYOU · 消息 + + 我的通知 + + + 订单、资金与系统通知;未读通知会在消息入口展示红点。 + +
+
+ 未读 + {notifications.filter(item => !item.read_at).length} + + + + +
+
+ + {notificationsQuery.isError ? ( + + ) : null} + +
+ {notifications.length === 0 && !notificationsQuery.isPending ? ( + + + + ) : null} + + {notifications.map(item => ( + + + {item.title} + {item.read_at ? ( + 已读 + ) : ( + + 未读 + + )} + + {formatDateTime(item.created_at)} + + + + {item.content} + + {item.read_at ? null : ( + + )} + + ))} +
+ + {notificationsQuery.hasNextPage ? ( + + ) : null} + + {page} + +
+ ) +} diff --git a/frontend/src/layouts/StorefrontLayout.tsx b/frontend/src/layouts/StorefrontLayout.tsx index b6ac846..125d9ab 100644 --- a/frontend/src/layouts/StorefrontLayout.tsx +++ b/frontend/src/layouts/StorefrontLayout.tsx @@ -32,6 +32,7 @@ export function StorefrontLayout() { 卖家中心 + 公告 流程 规则 @@ -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: '我的钱包',