feat(mobile): 补齐旧路由并新增足迹公告邀请页

This commit is contained in:
yml2213
2026-11-01 20:54:56 +08:00
parent 99899f4f11
commit 80b7a745da
4 changed files with 379 additions and 25 deletions
+27 -25
View File
@@ -6,7 +6,9 @@ import { NotFoundPage } from '@/features/NotFoundPage'
import { StorefrontLayout } from '@/layouts/StorefrontLayout'
const ListingListPage = lazy(() =>
import('@/features/listings/ListingListPage').then(module => ({ default: module.ListingListPage }))
import('@/features/listings/ListingListPage').then(module => ({
default: module.ListingListPage,
}))
)
const ListingDetailPage = lazy(() =>
import('@/features/listings/ListingDetailPage').then(module => ({
@@ -137,6 +139,21 @@ const MobileAnnouncementsPage = lazy(() =>
default: module.MobileAnnouncementsPage,
}))
)
const MobileAnnouncementDetailPage = lazy(() =>
import('@/features/mobile/MobileAnnouncementDetailPage').then(module => ({
default: module.MobileAnnouncementDetailPage,
}))
)
const MobileListingCollectionsPage = lazy(() =>
import('@/features/mobile/MobileListingCollectionsPage').then(module => ({
default: module.MobileListingCollectionsPage,
}))
)
const MobileInviteLandingPage = lazy(() =>
import('@/features/mobile/MobileInviteLandingPage').then(module => ({
default: module.MobileInviteLandingPage,
}))
)
const MobileNotificationsPage = lazy(() =>
import('@/features/mobile/MobileNotificationsPage').then(module => ({
default: module.MobileNotificationsPage,
@@ -532,6 +549,7 @@ export function AppRouter() {
<Route path="/m" element={<MobileLayout />}>
<Route index element={<MobileHomePage />} />
<Route path="listings" element={<MobileHomePage />} />
<Route path="listings/collections" element={<MobileListingCollectionsPage />} />
<Route path="listings/:id" element={<MobileListingDetailPage />} />
<Route path="orders" element={<MobileOrdersPage />} />
<Route path="orders/:id" element={<MobileOrderDetailPage />} />
@@ -545,8 +563,10 @@ export function AppRouter() {
<Route path="profile" element={<MobileProfilePage />} />
<Route path="wallet/withdrawal" element={<MobileWithdrawalPage />} />
<Route path="announcements" element={<MobileAnnouncementsPage />} />
<Route path="announcements/:id" element={<MobileAnnouncementDetailPage />} />
<Route path="notifications" element={<MobileNotificationsPage />} />
<Route path="invitations" element={<MobileInvitationsPage />} />
<Route path="i/:code" element={<MobileInviteLandingPage />} />
<Route path="register" element={<MobileRegisterPage />} />
<Route path="add-home-guide" element={<MobileAddHomeGuidePage />} />
<Route path="crash" element={<MobileCrashListPage />} />
@@ -598,39 +618,21 @@ export function AppRouter() {
<Route path="chat-support-groups" element={<AdminSupportGroupsPage />} />
<Route path="support-groups" element={<AdminSupportGroupsPage />} />
<Route path="distribution/sites" element={<AdminDistributionSitesPage />} />
<Route
path="distribution/dashboard"
element={<AdminDistributionDashboardPage />}
/>
<Route path="distribution/dashboard" element={<AdminDistributionDashboardPage />} />
<Route path="distribution/orders" element={<AdminDistributionOrdersPage />} />
<Route
path="distribution/settlements"
element={<AdminDistributionSettlementsPage />}
/>
<Route path="distribution/settlements" element={<AdminDistributionSettlementsPage />} />
<Route path="distribution/users" element={<AdminDistributionUsersPage />} />
<Route
path="distribution/announcements"
element={<AdminSiteAnnouncementsPage />}
/>
<Route
path="distribution/listings"
element={<AdminDistributionListingsPage />}
/>
<Route path="distribution/announcements" element={<AdminSiteAnnouncementsPage />} />
<Route path="distribution/listings" element={<AdminDistributionListingsPage />} />
<Route
path="distribution/payment-channels"
element={<AdminDistributionPaymentChannelsPage />}
/>
<Route path="distribution/wallet" element={<AdminDistributionWalletPage />} />
<Route path="distribution/sms" element={<AdminDistributionSMSPage />} />
<Route
path="distribution/invitation"
element={<AdminDistributionInvitationPage />}
/>
<Route path="distribution/invitation" element={<AdminDistributionInvitationPage />} />
<Route path="distribution/qrcodes" element={<AdminDistributionQrCodesPage />} />
<Route
path="distribution/qrcode-pool"
element={<AdminDistributionQrCodesPage />}
/>
<Route path="distribution/qrcode-pool" element={<AdminDistributionQrCodesPage />} />
<Route path="distribution/rules" element={<AdminDistributionRulesPage />} />
<Route
path="*"
@@ -0,0 +1,47 @@
import { Card, ErrorBlock, NavBar, Skeleton, Tag } from 'antd-mobile'
import { useNavigate, useParams } from 'react-router-dom'
import { useAnnouncement } from '@/features/announcements/api'
import { formatDateTime, formatText } from '@/shared/utils/format'
export function MobileAnnouncementDetailPage() {
const { id } = useParams()
const navigate = useNavigate()
const query = useAnnouncement(id)
const item = query.data
return (
<div className="mobile-wallet">
<NavBar onBack={() => navigate('/m/announcements')}>公告详情</NavBar>
{query.isError ? (
<ErrorBlock
status="disconnected"
title="公告加载失败"
description="公告可能已下线或不存在"
/>
) : null}
{query.isPending ? (
<Card>
<Skeleton.Paragraph lineCount={8} animated />
</Card>
) : !item ? null : (
<Card>
<div className="admin-stack" style={{ gap: 10 }}>
<h2 style={{ margin: 0, fontSize: 20, lineHeight: 1.4 }}>{item.title}</h2>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{item.is_pinned ? <Tag color="danger">置顶</Tag> : null}
{item.is_important ? <Tag color="warning">重要</Tag> : null}
<Tag>{formatText(item.category)}</Tag>
</div>
<span className="mobile-wallet__sub">
{formatDateTime(item.published_at || item.created_at)} · 阅读 {item.view_count}
</span>
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.9 }}>{item.content}</div>
</div>
</Card>
)}
</div>
)
}
@@ -0,0 +1,86 @@
import { useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Button, Card, ErrorBlock, Skeleton, Tag } from 'antd-mobile'
import { GiftOutline } from 'antd-mobile-icons'
import { useNavigate, useParams } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { fetchInviteCodeInfo } from '@/features/invitations/api'
const INVITE_STORAGE_KEY = 'jieyou_invite_code'
export function MobileInviteLandingPage() {
const { code } = useParams()
const navigate = useNavigate()
const infoQuery = useQuery({
queryKey: ['invite-code-info', code],
queryFn: () => fetchInviteCodeInfo(code as string),
enabled: Boolean(code),
retry: false,
})
useEffect(() => {
if (!code) return
try {
sessionStorage.setItem(INVITE_STORAGE_KEY, code)
} catch {
// 忽略存储失败
}
}, [code])
const info = infoQuery.data
return (
<div className="mobile-wallet">
<Card>
<div className="admin-stack" style={{ gap: 10 }}>
<BrandLogo height={30} />
<span className="mobile-wallet__sub">JIEYOU · 邀请</span>
<h2 style={{ margin: 0, fontSize: 22 }}>解忧商行邀请你加入</h2>
<div className="mobile-wallet__sub" style={{ lineHeight: 1.8 }}>
注册并完成实名后即可租号、出租账号;邀请你的好友还能获得首月 / 次月 / 后续返利。
</div>
</div>
</Card>
<Card>
{infoQuery.isPending ? (
<Skeleton.Paragraph lineCount={3} animated />
) : infoQuery.isError || !info?.valid ? (
<ErrorBlock
status="empty"
title="邀请码无效或已失效"
description="你仍然可以正常注册使用解忧商行,只是不会绑定邀请关系"
/>
) : (
<div className="admin-stack" style={{ gap: 8 }}>
<Tag color="warning">
<GiftOutline /> 邀请码 {info.invite_code}
</Tag>
<span className="mobile-wallet__sub">
邀请人:{info.inviter_nickname || '解忧商行用户'}
</span>
</div>
)}
</Card>
<Card>
<div className="admin-stack" style={{ gap: 10 }}>
<Button
block
color="primary"
onClick={() => navigate(`/m/register${code ? `?invite=${code}` : ''}`)}
>
立即注册
</Button>
<Button block onClick={() => navigate('/m/login')}>
已有账号,去登录
</Button>
<Button block fill="none" onClick={() => navigate('/m')}>
先逛逛
</Button>
</div>
</Card>
</div>
)
}
@@ -0,0 +1,219 @@
import { useMemo, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Button, Card, Dialog, ErrorBlock, NavBar, Skeleton, Tabs, Tag, Toast } from 'antd-mobile'
import { StarFill, StarOutline } from 'antd-mobile-icons'
import { useNavigate } from 'react-router-dom'
import {
addRemoteFavorite,
clearRemoteHistory,
fetchRemoteCollections,
removeRemoteFavorite,
removeRemoteHistory,
} from '@/features/listings/collectionsApi'
import { useListingCollections } from '@/features/listings/collections'
import { useSessionStore } from '@/stores/session'
import { formatCoinM, formatDateTime, formatMoney } from '@/shared/utils/format'
type TabKey = 'history' | 'favorite'
export function MobileListingCollectionsPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const token = useSessionStore(state => state.token)
const local = useListingCollections()
const [tab, setTab] = useState<TabKey>('history')
const remote = useQuery({
queryKey: ['listing-collections'],
queryFn: async () => {
const [history, favorites] = await Promise.all([
fetchRemoteCollections('history'),
fetchRemoteCollections('favorite'),
])
return { history: history.items, favorites: favorites.items, counts: history.counts }
},
enabled: Boolean(token),
})
const historyItems = useMemo(
() =>
token
? (remote.data?.history ?? []).map(item => ({
listing: item.listing,
occurredAt: item.occurred_at,
}))
: local.history.map(item => ({ listing: item.listing, occurredAt: item.viewed_at })),
[token, remote.data, local.history]
)
const favoriteItems = useMemo(
() =>
token
? (remote.data?.favorites ?? []).map(item => ({
listing: item.listing,
occurredAt: item.occurred_at,
}))
: local.favorites.map(item => ({ listing: item.listing, occurredAt: item.favorited_at })),
[token, remote.data, local.favorites]
)
const items = tab === 'favorite' ? favoriteItems : historyItems
const favoriteIDs = new Set(favoriteItems.map(item => item.listing.id))
const loading = Boolean(token) && remote.isPending
const refresh = () => queryClient.invalidateQueries({ queryKey: ['listing-collections'] })
const onToggle = async (listingID: number) => {
const favorited = favoriteIDs.has(listingID)
try {
if (token) {
if (favorited) {
await removeRemoteFavorite(listingID)
} else {
await addRemoteFavorite(listingID)
}
await refresh()
} else {
const item = [...historyItems, ...favoriteItems].find(row => row.listing.id === listingID)
if (item) local.toggleFavorite(item.listing)
}
Toast.show({ content: favorited ? '已取消收藏' : '已收藏' })
} catch {
Toast.show({ content: '操作失败,请稍后再试' })
}
}
const onRemove = async (listingID: number) => {
try {
if (token) {
if (tab === 'favorite') {
await removeRemoteFavorite(listingID)
} else {
await removeRemoteHistory(listingID)
}
await refresh()
} else if (tab === 'favorite') {
local.removeFavorite(listingID)
} else {
local.removeHistory(listingID)
}
Toast.show({ content: tab === 'favorite' ? '已取消收藏' : '已移除记录' })
} catch {
Toast.show({ content: '操作失败,请稍后再试' })
}
}
const onClearHistory = async () => {
const confirmed = await Dialog.confirm({
title: '清空浏览记录',
content: '确认清空全部浏览记录吗?收藏的账号不受影响。',
confirmText: '清空',
cancelText: '取消',
})
if (!confirmed) return
try {
if (token) {
await clearRemoteHistory()
await refresh()
} else {
local.clearHistory()
}
Toast.show({ content: '浏览记录已清空' })
} catch {
Toast.show({ content: '清空失败,请稍后再试' })
}
}
return (
<div className="mobile-wallet">
<NavBar onBack={() => navigate('/m/profile')}>我的足迹</NavBar>
<Card>
<div className="mobile-wallet__sub">
{token ? '浏览与收藏已同步到账号,换设备登录也能找回。' : '未登录时记录仅保存在本机。'}
</div>
</Card>
<Tabs activeKey={tab} onChange={key => setTab(key as TabKey)}>
<Tabs.Tab title={`浏览记录 ${historyItems.length}`} key="history" />
<Tabs.Tab title={`收藏账号 ${favoriteItems.length}`} key="favorite" />
</Tabs>
{tab === 'history' && historyItems.length > 0 ? (
<Card>
<Button block size="small" onClick={() => void onClearHistory()}>
清空浏览记录
</Button>
</Card>
) : null}
{loading ? (
<Card>
<Skeleton.Paragraph lineCount={4} animated />
</Card>
) : items.length === 0 ? (
<ErrorBlock status="empty" title={tab === 'favorite' ? '暂无收藏账号' : '暂无浏览记录'} />
) : (
<div className="admin-stack" style={{ gap: 10 }}>
{items.map(item => (
<Card key={item.listing.id} onClick={() => navigate(`/m/listings/${item.listing.id}`)}>
<div className="admin-stack" style={{ gap: 6 }}>
<div style={{ display: 'flex', gap: 10 }}>
{item.listing.cover_url ? (
<img
src={item.listing.cover_url}
alt={item.listing.title}
loading="lazy"
style={{ width: 84, height: 64, objectFit: 'cover', borderRadius: 8 }}
/>
) : null}
<div className="admin-stack" style={{ gap: 4, minWidth: 0 }}>
<span className="mobile-wallet__sub">{item.listing.listing_no}</span>
<strong style={{ wordBreak: 'break-word' }}>{item.listing.title}</strong>
<span className="mobile-wallet__sub">
{item.listing.server_region || '区服待定'} ·{' '}
{item.listing.login_platform || '登录方式待定'}
</span>
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<Tag color="primary">{formatMoney(item.listing.price_cent)}</Tag>
<Tag>押金 {formatMoney(item.listing.deposit_amount_cent)}</Tag>
<Tag>哈夫币 {formatCoinM(item.listing.haf_coin_amount)}</Tag>
<Tag color={item.listing.in_transaction ? 'warning' : 'success'}>
{item.listing.in_transaction ? '租赁中' : '可租'}
</Tag>
</div>
<span className="mobile-wallet__sub">{formatDateTime(item.occurredAt)}</span>
<div style={{ display: 'flex', gap: 8 }}>
<Button
size="mini"
onClick={event => {
event.stopPropagation()
void onToggle(item.listing.id)
}}
>
{favoriteIDs.has(item.listing.id) ? <StarFill /> : <StarOutline />}
{favoriteIDs.has(item.listing.id) ? '已收藏' : '收藏'}
</Button>
<Button
size="mini"
onClick={event => {
event.stopPropagation()
void onRemove(item.listing.id)
}}
>
移除
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
)
}