feat(mobile): 新增公告、通知与邀请页
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user