diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index b9f878b..34c9861 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -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() { }> } /> } /> + } /> } /> } /> } /> @@ -545,8 +563,10 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> + } /> } /> } /> } /> @@ -598,39 +618,21 @@ export function AppRouter() { } /> } /> } /> - } - /> + } /> } /> - } - /> + } /> } /> - } - /> - } - /> + } /> + } /> } /> } /> } /> - } - /> + } /> } /> - } - /> + } /> } /> + navigate('/m/announcements')}>公告详情 + + {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/mobile/MobileInviteLandingPage.tsx b/frontend/src/features/mobile/MobileInviteLandingPage.tsx new file mode 100644 index 0000000..fa7dd70 --- /dev/null +++ b/frontend/src/features/mobile/MobileInviteLandingPage.tsx @@ -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 ( +
+ +
+ + JIEYOU · 邀请 +

解忧商行邀请你加入

+
+ 注册并完成实名后即可租号、出租账号;邀请你的好友还能获得首月 / 次月 / 后续返利。 +
+
+
+ + + {infoQuery.isPending ? ( + + ) : infoQuery.isError || !info?.valid ? ( + + ) : ( +
+ + 邀请码 {info.invite_code} + + + 邀请人:{info.inviter_nickname || '解忧商行用户'} + +
+ )} +
+ + +
+ + + +
+
+
+ ) +} diff --git a/frontend/src/features/mobile/MobileListingCollectionsPage.tsx b/frontend/src/features/mobile/MobileListingCollectionsPage.tsx new file mode 100644 index 0000000..47545c3 --- /dev/null +++ b/frontend/src/features/mobile/MobileListingCollectionsPage.tsx @@ -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('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 ( +
+ navigate('/m/profile')}>我的足迹 + + +
+ {token ? '浏览与收藏已同步到账号,换设备登录也能找回。' : '未登录时记录仅保存在本机。'} +
+
+ + setTab(key as TabKey)}> + + + + + {tab === 'history' && historyItems.length > 0 ? ( + + + + ) : null} + + {loading ? ( + + + + ) : items.length === 0 ? ( + + ) : ( +
+ {items.map(item => ( + navigate(`/m/listings/${item.listing.id}`)}> +
+
+ {item.listing.cover_url ? ( + {item.listing.title} + ) : null} +
+ {item.listing.listing_no} + {item.listing.title} + + {item.listing.server_region || '区服待定'} ·{' '} + {item.listing.login_platform || '登录方式待定'} + +
+
+ +
+ {formatMoney(item.listing.price_cent)} + 押金 {formatMoney(item.listing.deposit_amount_cent)} + 哈夫币 {formatCoinM(item.listing.haf_coin_amount)} + + {item.listing.in_transaction ? '租赁中' : '可租'} + +
+ + {formatDateTime(item.occurredAt)} + +
+ + +
+
+
+ ))} +
+ )} +
+ ) +}