feat(mobile): 新增公告、通知与邀请页

This commit is contained in:
yml2213
2026-10-30 17:18:59 +08:00
parent 96faee026b
commit 2f0490c270
4 changed files with 263 additions and 0 deletions
+18
View File
@@ -110,6 +110,21 @@ const MobileWithdrawalPage = lazy(() =>
default: module.MobileWithdrawalPage,
}))
)
const MobileAnnouncementsPage = lazy(() =>
import('@/features/mobile/MobileAnnouncementsPage').then(module => ({
default: module.MobileAnnouncementsPage,
}))
)
const MobileNotificationsPage = lazy(() =>
import('@/features/mobile/MobileNotificationsPage').then(module => ({
default: module.MobileNotificationsPage,
}))
)
const MobileInvitationsPage = lazy(() =>
import('@/features/mobile/MobileInvitationsPage').then(module => ({
default: module.MobileInvitationsPage,
}))
)
const MobileOrdersPage = lazy(() =>
import('@/features/mobile/MobileOrdersPage').then(module => ({
default: module.MobileOrdersPage,
@@ -430,6 +445,9 @@ export function AppRouter() {
<Route path="seller/listings/:id/edit" element={<MobileSellerFormPage />} />
<Route path="profile" element={<MobileProfilePage />} />
<Route path="wallet/withdrawal" element={<MobileWithdrawalPage />} />
<Route path="announcements" element={<MobileAnnouncementsPage />} />
<Route path="notifications" element={<MobileNotificationsPage />} />
<Route path="invitations" element={<MobileInvitationsPage />} />
</Route>
<Route path={`${adminBase}/login`} element={<AdminLoginPage />} />
@@ -0,0 +1,62 @@
import { useState } from 'react'
import { Card, Dialog, ErrorBlock, NavBar, Skeleton, Tag } from 'antd-mobile'
import { useNavigate } from 'react-router-dom'
import { useAnnouncements } from '@/features/announcements/api'
import type { Announcement } from '@/features/announcements/types'
import { formatDate, formatText } from '@/shared/utils/format'
export function MobileAnnouncementsPage() {
const navigate = useNavigate()
const [page, setPage] = useState(1)
const query = useAnnouncements(page, 20)
const items = query.data?.items ?? []
const openDetail = async (item: Announcement) => {
await Dialog.alert({
title: item.title,
content: (
<div style={{ whiteSpace: 'pre-wrap', textAlign: 'left', lineHeight: 1.8 }}>
{item.content}
</div>
),
confirmText: '知道了',
})
}
return (
<div className="mobile-wallet">
<NavBar onBack={() => navigate('/m/profile')}>公告</NavBar>
{query.isPending ? (
<Card>
<Skeleton.Paragraph lineCount={4} animated />
</Card>
) : items.length === 0 ? (
<ErrorBlock status="empty" title="暂无公告" />
) : (
<div className="admin-stack" style={{ gap: 10 }}>
{items.map(item => (
<Card key={item.id} onClick={() => void openDetail(item)}>
<div className="admin-stack" style={{ gap: 4 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
{item.is_pinned ? <Tag color="danger">置顶</Tag> : null}
{item.is_important ? <Tag color="warning">重要</Tag> : null}
<strong>{item.title}</strong>
</div>
<div className="mobile-wallet__sub">{formatText(item.content).slice(0, 80)}…</div>
<div className="mobile-wallet__sub">
{formatDate(item.published_at || item.created_at)} · 阅读 {item.view_count}
</div>
</div>
</Card>
))}
</div>
)}
{query.data && items.length < (query.data.total ?? 0) ? (
<Card onClick={() => setPage(current => current + 1)}>加载更多</Card>
) : null}
</div>
)
}
@@ -0,0 +1,100 @@
import { Button, Card, ErrorBlock, NavBar, Skeleton, Tag, Toast } from 'antd-mobile'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { fetchInvitedRecords, useInvitationSummary } from '@/features/invitations/api'
import { useSessionStore } from '@/stores/session'
import { formatDate, formatMoney } from '@/shared/utils/format'
const stageLabels: Record<string, string> = {
first_month: '首月',
second_month: '次月',
subsequent: '后续',
unverified: '待实名',
}
export function MobileInvitationsPage() {
const navigate = useNavigate()
const user = useSessionStore(state => state.user)
const summaryQuery = useInvitationSummary(Boolean(user))
const recordsQuery = useQuery({
queryKey: ['invitation-records', 1],
queryFn: () => fetchInvitedRecords(1, 10),
enabled: Boolean(user),
})
const summary = summaryQuery.data
const records = recordsQuery.data?.items ?? []
const copyLink = async () => {
if (!summary?.invite_link) return
try {
await navigator.clipboard.writeText(summary.invite_link)
Toast.show({ icon: 'success', content: '邀请链接已复制' })
} catch {
Toast.show({ content: '复制失败,请手动复制' })
}
}
return (
<div className="mobile-wallet">
<NavBar onBack={() => navigate('/m/profile')}>邀请好友</NavBar>
<Card>
{summaryQuery.isPending ? (
<Skeleton.Paragraph lineCount={3} animated />
) : (
<div className="admin-stack" style={{ gap: 6 }}>
<div className="mobile-wallet__label">累计返利</div>
<div className="mobile-wallet__amount">
{formatMoney(summary?.total_rebate_cent ?? 0)}
</div>
<div className="mobile-wallet__sub">
邀请码 {summary?.invite_code || '—'} · 已邀请 {summary?.total_invited_count ?? 0} 人
</div>
<div className="mobile-wallet__sub">
首月 {Math.round((summary?.first_month_rate ?? 0) * 100)}% · 次月{' '}
{Math.round((summary?.second_month_rate ?? 0) * 100)}% · 后续{' '}
{Math.round((summary?.subsequent_rate ?? 0) * 100)}%
</div>
<Button
color="primary"
fill="outline"
size="small"
disabled={!summary?.invite_link}
onClick={() => void copyLink()}
>
复制邀请链接
</Button>
</div>
)}
</Card>
<Card title="邀请记录">
{recordsQuery.isPending ? (
<Skeleton.Paragraph lineCount={3} animated />
) : records.length === 0 ? (
<ErrorBlock status="empty" title="还没有邀请记录" />
) : (
<div className="admin-stack" style={{ gap: 8 }}>
{records.map(item => (
<div key={item.id} className="admin-stack" style={{ gap: 2 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<strong>{item.nickname || item.masked_phone}</strong>
<Tag color={item.status === 'active' ? 'success' : 'warning'}>
{item.status === 'active' ? '已生效' : '待实名'}
</Tag>
<Tag>{stageLabels[item.cycle_stage] ?? item.cycle_stage}</Tag>
</div>
<div className="mobile-wallet__sub">
累计贡献 {formatMoney(item.total_earned_from_cent)} · 注册{' '}
{formatDate(item.registered_at)}
</div>
</div>
))}
</div>
)}
</Card>
</div>
)
}
@@ -0,0 +1,83 @@
import { Button, Card, ErrorBlock, NavBar, Skeleton, Tag, Toast } from 'antd-mobile'
import { useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
markAllNotificationsRead,
markNotificationRead,
useNotifications,
} from '@/features/messages/api'
import { useSessionStore } from '@/stores/session'
import { formatDateTime } from '@/shared/utils/format'
export function MobileNotificationsPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const user = useSessionStore(state => state.user)
const query = useNotifications(Boolean(user))
const items = query.data?.pages.flatMap(page => page.items) ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['notifications'] })
return (
<div className="mobile-wallet">
<NavBar
onBack={() => navigate('/m/profile')}
right={
<Button
size="mini"
fill="none"
onClick={async () => {
await markAllNotificationsRead()
Toast.show({ icon: 'success', content: '已全部标记为已读' })
await invalidate()
}}
>
全部已读
</Button>
}
>
通知
</NavBar>
{query.isPending ? (
<Card>
<Skeleton.Paragraph lineCount={4} animated />
</Card>
) : items.length === 0 ? (
<ErrorBlock status="empty" title="还没有通知" />
) : (
<div className="admin-stack" style={{ gap: 10 }}>
{items.map(item => (
<Card
key={item.id}
onClick={async () => {
if (item.read_at) return
await markNotificationRead(item.id)
await invalidate()
}}
>
<div className="admin-stack" style={{ gap: 4 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<strong>{item.title}</strong>
{item.read_at ? null : <Tag color="danger">未读</Tag>}
</div>
<div className="mobile-wallet__sub">{item.content}</div>
<div className="mobile-wallet__sub">{formatDateTime(item.created_at)}</div>
</div>
</Card>
))}
</div>
)}
{query.hasNextPage ? (
<Button
block
loading={query.isFetchingNextPage}
onClick={() => void query.fetchNextPage()}
>
加载更多
</Button>
) : null}
</div>
)
}