feat(mobile): 补齐旧路由并新增足迹公告邀请页
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user