feat(mobile): 重构移动端首页与个人中心

This commit is contained in:
yml
2026-11-12 22:48:18 +08:00
parent a4441bba9a
commit adc5a905da
11 changed files with 1902 additions and 232 deletions
+2
View File
@@ -4,6 +4,7 @@ import zhCN from 'antd/locale/zh_CN'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import { MobileRouteGate } from '@/app/MobileRouteGate'
import { AppRouter } from '@/app/AppRouter'
import { ChatEventsBridge } from '@/features/messages/ChatEventsBridge'
import { useSessionStore } from '@/stores/session'
@@ -41,6 +42,7 @@ export default function App() {
<AntApp>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<MobileRouteGate />
<SessionBootstrap />
<ChatEventsBridge />
<AppRouter />
+33
View File
@@ -0,0 +1,33 @@
import { useLayoutEffect } from 'react'
import { useLocation } from 'react-router-dom'
import {
getMobilePath,
isMobileDevice,
prefersDesktopSite,
setDesktopPreference,
} from '@/shared/utils/mobileHost'
export function MobileRouteGate() {
const location = useLocation()
useLayoutEffect(() => {
const params = new URLSearchParams(location.search)
if (params.get('desktop') === '1') {
setDesktopPreference(true)
params.delete('desktop')
const search = params.toString()
window.location.replace(`${location.pathname}${search ? `?${search}` : ''}${location.hash}`)
return
}
if (prefersDesktopSite() || !isMobileDevice()) return
const mobilePath = getMobilePath(location.pathname)
if (mobilePath === location.pathname) return
window.location.replace(`${mobilePath}${location.search}${location.hash}`)
}, [location.hash, location.pathname, location.search])
return null
}
+15 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { apiClient, unwrapData } from '@/shared/api/client'
import type { ApiResponse, Listing, PublicListingPage } from '@/shared/api/types'
@@ -60,6 +60,20 @@ export function useListings(query: ListingQuery) {
})
}
export function useInfiniteListings(query: ListingQuery) {
return useInfiniteQuery({
queryKey: ['listings-infinite', query],
initialPageParam: 1,
queryFn: ({ pageParam }) =>
fetchListings({
...query,
page: pageParam,
}),
getNextPageParam: lastPage =>
lastPage.page * lastPage.page_size < lastPage.total ? lastPage.page + 1 : undefined,
})
}
export function fetchListingDetail(id: number | string) {
return unwrapData<Listing>(apiClient.get<ApiResponse<Listing>>(`/listings/${id}`))
}
+499 -154
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useMemo, useState, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
Button,
@@ -8,33 +8,75 @@ import {
PullToRefresh,
SearchBar,
Skeleton,
Swiper,
Tag,
} from 'antd-mobile'
import { DownOutline, RightOutline, SearchOutline } from 'antd-mobile-icons'
import { Link } from 'react-router-dom'
import {
AddCircleOutline,
BillOutline,
CheckShieldOutline,
DownOutline,
FlagOutline,
GiftOutline,
PhoneFill,
RightOutline,
SearchOutline,
ShopbagOutline,
SmileOutline,
} from 'antd-mobile-icons'
import { Link, useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { useListings } from '@/features/listings/api'
import {
fallbackBanners,
useHomeConfig,
type HomeBannerItem,
type HomeEntryConfig,
} from '@/features/home/api'
import { useInfiniteListings } from '@/features/listings/api'
import {
banRecordText,
faceOwnerText,
gameNameChangeText,
listingRatioText,
listingRentalDays,
listingResources,
onlineTimeText,
resourceBadgeText,
} from '@/features/listings/labels'
import {
awmRanges,
coinRanges,
dailyLossRanges,
depositRanges,
emptyListingFilters,
fireLevelRanges,
priceRanges,
ratioRanges,
secretKdRanges,
totalRanges,
type ListingFilterState,
} from '@/features/listings/ListingFilterPanel'
import { fetchListingPublishOptions } from '@/features/seller/options'
import { formatCoinM } from '@/shared/utils/format'
import type { Listing } from '@/shared/api/types'
const sortOptions = [
{ value: '', label: '综合推荐' },
{ value: 'published', label: '最新发布' },
{ value: 'ratioDesc', label: '比例最高' },
{ value: 'ratioAsc', label: '比例最低' },
{ value: 'priceAsc', label: '价格最低' },
{ value: 'priceDesc', label: '价格最高' },
{ value: 'coinDesc', label: '哈夫币最多' },
{ value: 'coinAsc', label: '哈夫币最少' },
{ value: 'awmDesc', label: 'AWM最多' },
{ value: 'awmAsc', label: 'AWM最少' },
]
const zoneTabs = [
{ key: 'all', label: '全部' },
{ key: 'premium', label: '极品' },
{ key: 'sale', label: '特惠' },
{ key: 'gift', label: '赠送' },
{ key: 'night', label: '夜间' },
@@ -42,18 +84,24 @@ const zoneTabs = [
{ key: 'highCoin', label: '高币' },
]
const sortOptions = [
{ value: '', label: '综合推荐' },
{ value: 'published', label: '最新发布' },
{ value: 'ratio', label: '比例优先' },
{ value: 'coinDesc', label: '哈夫币从高到低' },
{ value: 'priceAsc', label: '价格从低到高' },
]
const insuranceOptions = ['2*1', '2*2', '2*3', '3*3']
const levelOptions = ['4级', '5级', '6级', '7级']
const loginOptions = ['扫码', '账号密码']
type MultiFilterKey =
'insurance' | 'stamina' | 'load' | 'loginMethod' | 'ranks' | 'regions' | 'skinNames'
type RangeFilterKey =
| 'coinKey'
| 'priceKey'
| 'ratioKey'
| 'awmKey'
| 'depositKey'
| 'totalKey'
| 'fireLevelKey'
| 'dailyLossKey'
| 'secretKdKey'
function money(cents: number) {
return `¥${(cents / 100).toFixed(2)}`
}
@@ -75,6 +123,7 @@ function MobileChipGroup({
selected: string[]
onToggle: (option: string) => void
}) {
if (options.length === 0) return null
return (
<div className="mobile-filter-group">
<span className="mobile-filter-group__label">{label}</span>
@@ -94,32 +143,88 @@ function MobileChipGroup({
)
}
function MobileRangeGroup({
label,
options,
activeKey,
onSelect,
}: {
label: string
options: { key: string; label: string }[]
activeKey?: string
onSelect: (key: string) => void
}) {
return (
<div className="mobile-filter-group">
<span className="mobile-filter-group__label">{label}</span>
<div className="mobile-filter-chips">
{options.map(option => (
<button
key={option.key}
type="button"
className={`mobile-filter-chip${activeKey === option.key ? ' is-active' : ''}`}
onClick={() => onSelect(option.key)}
>
{option.label}
</button>
))}
</div>
</div>
)
}
function MobileListingCard({ item }: { item: Listing }) {
const [coverFailed, setCoverFailed] = useState(false)
const online = onlineTimeText(item.asset_summary)
const rentalDays = listingRentalDays(item)
const faceOwner = faceOwnerText(item.asset_summary)
const banRecord = banRecordText(item.asset_summary)
const gameNameChange = gameNameChangeText(item.asset_summary)
const ratio = listingRatioText(item)
const resources = listingResources(item.asset_summary).slice(0, 2)
const specs = [
assetText(item, 'season_insurance') ? `保险 ${assetText(item, 'season_insurance')}` : '',
assetText(item, 'stamina_level') ? `体力 ${assetText(item, 'stamina_level')}` : '',
assetText(item, 'load_level') ? `负重 ${assetText(item, 'load_level')}` : '',
online ? `在线 ${online}` : '',
rentalDays ? `预计租期 ${rentalDays} 天` : '',
faceOwner ? `人脸 ${faceOwner}` : '',
banRecord ? `封禁 ${banRecord}` : '',
gameNameChange ? `改名 ${gameNameChange}` : '',
].filter(Boolean)
{ label: '保险', value: assetText(item, 'season_insurance') },
{ label: '体力', value: assetText(item, 'stamina_level') },
{ label: '负重', value: assetText(item, 'load_level') },
{ label: '区服', value: item.server_region },
{ label: '租期', value: rentalDays ? `${rentalDays}天` : '' },
{ label: '在线', value: online },
{ label: '人脸', value: faceOwner },
{ label: '封禁', value: banRecord },
{ label: '改名', value: gameNameChange },
]
.filter(spec => spec.value)
.slice(0, 5)
return (
<Link to={`/m/listings/${item.id}`} className="mobile-listing-card">
<div className="mobile-listing-card__cover">
{item.cover_url ? (
<img src={item.cover_url} alt={item.title} loading="lazy" />
{item.cover_url && !coverFailed ? (
<img
src={item.cover_url}
alt={item.title}
loading="lazy"
onError={() => setCoverFailed(true)}
/>
) : (
<BrandLogo height={38} variant="seal" />
<div className="mobile-listing-card__fallback">
<BrandLogo height={36} variant="seal" />
<span>实拍图整理中</span>
</div>
)}
{item.is_premium ||
item.owner_is_quality_owner ||
item.compensation_plan === 'insurance' ? (
<div className="mobile-listing-card__badges">
{item.owner_is_quality_owner ? <span>优质号主</span> : null}
{item.is_premium ? <span className="is-premium">极品</span> : null}
{item.compensation_plan === 'insurance' ? (
<span className="is-insurance">保险</span>
) : null}
</div>
) : null}
</div>
<div className="mobile-listing-card__body">
<div className="mobile-listing-card__top">
<span className="mobile-listing-card__no">{item.listing_no}</span>
@@ -127,22 +232,35 @@ function MobileListingCard({ item }: { item: Listing }) {
{item.in_transaction ? '租赁中' : '可租'}
</Tag>
</div>
<h3>{item.title}</h3>
<div className="mobile-listing-card__core">
<strong>{formatCoinM(item.haf_coin_amount)}</strong>
{ratio ? <span>1:{ratio}</span> : null}
</div>
<div className="mobile-listing-card__tags">
<span>{item.server_region || '区服待定'}</span>
<span>{item.rank_level || '段位待定'}</span>
<span>哈夫币 {formatCoinM(item.haf_coin_amount)}</span>
{specs.map(spec => (
<span key={spec}>{spec}</span>
<span key={`${spec.label}-${spec.value}`}>
<em>{spec.label}</em>
{spec.value}
</span>
))}
{resources.map(resource => (
<span key={resource.label}>
<em>{resource.label}</em>
{resource.quantity}
<i>{resourceBadgeText(resource, 'compact')}</i>
</span>
))}
</div>
<div className="mobile-listing-card__bottom">
<div>
<strong>{money(item.price_cent)}</strong>
<small>押金 {money(item.deposit_amount_cent)}</small>
</div>
<span className="mobile-listing-card__more">
查看 <RightOutline />
<span className="mobile-listing-card__cta">
立即租 <RightOutline />
</span>
</div>
</div>
@@ -150,7 +268,44 @@ function MobileListingCard({ item }: { item: Listing }) {
)
}
function QuickService({
to,
icon,
label,
hint,
onClick,
}: {
to?: string
icon: ReactNode
label: string
hint: string
onClick?: () => void
}) {
const content = (
<>
<span className="mobile-quick-service__icon">{icon}</span>
<strong>{label}</strong>
<small>{hint}</small>
</>
)
if (to) {
return (
<Link to={to} className="mobile-quick-service">
{content}
</Link>
)
}
return (
<button type="button" className="mobile-quick-service" onClick={onClick}>
{content}
</button>
)
}
export function MobileHomePage() {
const navigate = useNavigate()
const [keyword, setKeyword] = useState('')
const [searchValue, setSearchValue] = useState('')
const [zone, setZone] = useState('all')
@@ -159,50 +314,99 @@ export function MobileHomePage() {
const [draft, setDraft] = useState<ListingFilterState>(emptyListingFilters)
const [filterOpen, setFilterOpen] = useState(false)
const [sortOpen, setSortOpen] = useState(false)
const [entryPreview, setEntryPreview] = useState<HomeEntryConfig | null>(null)
const homeConfig = useHomeConfig()
const publishOptions = useQuery({
queryKey: ['listing-publish-options'],
queryFn: fetchListingPublishOptions,
staleTime: 300_000,
})
const quickSkinGroups = (publishOptions.data?.skin_groups ?? []).filter(group =>
['melee', 'operatorRed'].includes(group.key)
)
const coin = coinRanges.find(range => range.key === filters.coinKey)
const price = priceRanges.find(range => range.key === filters.priceKey)
const ratio = ratioRanges.find(range => range.key === filters.ratioKey)
const awm = awmRanges.find(range => range.key === filters.awmKey)
const deposit = depositRanges.find(range => range.key === filters.depositKey)
const total = totalRanges.find(range => range.key === filters.totalKey)
const fireLevel = fireLevelRanges.find(range => range.key === filters.fireLevelKey)
const dailyLoss = dailyLossRanges.find(range => range.key === filters.dailyLossKey)
const secretKd = secretKdRanges.find(range => range.key === filters.secretKdKey)
const { data, isPending, isError, refetch } = useListings({
page: 1,
page_size: 20,
keyword,
zone: zone === 'all' ? '' : zone,
sort,
min_coin: coin?.min,
max_coin: coin?.max,
min_price: price?.min,
max_price: price?.max,
min_ratio: ratio?.min,
max_ratio: ratio?.max,
insurance: filters.insurance.join(','),
stamina: filters.stamina.join(','),
load: filters.load.join(','),
login_method: filters.loginMethod.join(','),
skin_name: filters.skinNames.join(','),
})
const listingQuery = useMemo(
() => ({
page_size: 10,
keyword,
zone: zone === 'all' ? '' : zone,
sort,
min_coin: coin?.min,
max_coin: coin?.max,
min_price: price?.min,
max_price: price?.max,
min_ratio: ratio?.min,
max_ratio: ratio?.max,
min_deposit: deposit?.min,
max_deposit: deposit?.max,
min_total: total?.min,
max_total: total?.max,
min_fire_level: fireLevel?.min,
max_fire_level: fireLevel?.max,
min_daily_loss: dailyLoss?.min,
max_daily_loss: dailyLoss?.max,
min_secret_kd: secretKd?.min,
max_secret_kd: secretKd?.max,
resource_awmAmmo_min: awm?.min,
resource_awmAmmo_max: awm?.max,
insurance: filters.insurance.join(','),
stamina: filters.stamina.join(','),
load: filters.load.join(','),
login_method: filters.loginMethod.join(','),
rank: filters.ranks.join(','),
region: filters.regions.join(','),
skin_name: filters.skinNames.join(','),
}),
[
awm,
coin,
dailyLoss,
deposit,
filters,
fireLevel,
keyword,
price,
ratio,
secretKd,
sort,
total,
zone,
]
)
const listings = useInfiniteListings(listingQuery)
const items = listings.data?.pages.flatMap(page => page.items) ?? []
const totalListings = listings.data?.pages[0]?.total ?? 0
const zoneCounts = listings.data?.pages[0]?.zone_counts ?? {}
const quickSkinGroups = (publishOptions.data?.skin_groups ?? []).filter(group =>
['melee', 'operatorRed'].includes(group.key)
)
const rankOptions = publishOptions.data?.rank_options ?? []
const regionOptions = publishOptions.data?.region_options ?? []
const applyFilter = () => {
setFilters(draft)
setFilterOpen(false)
}
const resetDraft = () => setDraft(emptyListingFilters)
const resetFilters = () => {
setDraft(emptyListingFilters)
setFilters(emptyListingFilters)
setKeyword('')
setSearchValue('')
setZone('all')
setSort('')
}
const toggleDraft = (
key: 'insurance' | 'stamina' | 'load' | 'loginMethod' | 'skinNames',
option: string
) => {
const toggleDraft = (key: MultiFilterKey, option: string) => {
const current = draft[key]
setDraft({
...draft,
@@ -212,7 +416,7 @@ export function MobileHomePage() {
})
}
const toggleRange = (key: 'coinKey' | 'priceKey' | 'ratioKey', nextKey: string) => {
const toggleRange = (key: RangeFilterKey, nextKey: string) => {
setDraft({ ...draft, [key]: draft[key] === nextKey ? undefined : nextKey })
}
@@ -221,44 +425,141 @@ export function MobileHomePage() {
filters.stamina.length +
filters.load.length +
filters.loginMethod.length +
filters.ranks.length +
filters.regions.length +
filters.skinNames.length +
[filters.coinKey, filters.priceKey, filters.ratioKey].filter(Boolean).length
[
filters.coinKey,
filters.priceKey,
filters.ratioKey,
filters.awmKey,
filters.depositKey,
filters.totalKey,
filters.fireLevelKey,
filters.dailyLossKey,
filters.secretKdKey,
].filter(Boolean).length
const sortLabel = sortOptions.find(option => option.value === sort)?.label ?? '综合推荐'
const sortLabel = sortOptions.find(option => option.value === sort)?.label ?? '综合排序'
const banners = (homeConfig.data?.banners ?? fallbackBanners)
.filter(banner => banner.enabled !== false)
.slice(0, 4)
const runBannerAction = (banner: HomeBannerItem) => {
const action = banner.mobile?.action
if (!action || action.type === 'none' || !action.target) return
if (action.type === 'route') {
navigate(action.target)
return
}
window.open(action.target, action.open_new_tab ? '_blank' : '_self')
}
const submitSearch = (value: string) => {
setKeyword(value.trim())
void listings.refetch()
}
const scrollToListings = () => {
document.getElementById('listings')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return (
<div className="mobile-home">
<section className="mobile-hero">
<div className="mobile-hero__eyebrow">JIEYOU TRADING HOUSE</div>
<h1>
租得明白,
<br />
还得安心。
</h1>
<p>实名审核 · 流程留痕 · 账单逐项列明</p>
<section className="mobile-home-search">
<div className="mobile-home-search__heading">
<div>
<span>解忧柜选</span>
<strong>今天想租什么账号?</strong>
</div>
<Link to="/m/announcements">规则</Link>
</div>
<SearchBar
className="mobile-search"
value={searchValue}
onChange={setSearchValue}
onSearch={value => setKeyword(value.trim())}
placeholder="搜索区服、段位、装备或商品号"
onSearch={submitSearch}
placeholder="搜编号 / 区服 / 段位 / 资产"
searchIcon={<SearchOutline />}
/>
</section>
<section className="mobile-trust">
<span>实名审核</span>
<span>交接留痕</span>
<span>争议可申诉</span>
<span>
<CheckShieldOutline />
<em>实名审核</em>
</span>
<span>
<SmileOutline />
<em>找回包赔</em>
</span>
<span>
<PhoneFill />
<em>客服护航</em>
</span>
<span>
<BillOutline />
<em>账单透明</em>
</span>
</section>
<section className="mobile-list-section">
<div className="mobile-section-head">
<section className="mobile-banner">
<Swiper autoplay loop indicatorProps={{ color: 'white' }}>
{banners.map((banner, index) => {
const imageURL = banner.mobile?.image_url || banner.image_url
return (
<Swiper.Item key={`${banner.title || 'banner'}-${index}`}>
<button
type="button"
className={`mobile-banner__item tone-${banner.tone || 'green'}`}
onClick={() => runBannerAction(banner)}
>
{imageURL ? (
<img src={imageURL} alt={banner.mobile?.alt || banner.title || ''} />
) : (
<span className="mobile-banner__copy">
{banner.badge ? <em>{banner.badge}</em> : null}
<strong>{banner.title || '平台担保交易'}</strong>
<small>{banner.pill || banner.eyebrow || '实名审核 · 交接留痕'}</small>
</span>
)}
</button>
</Swiper.Item>
)
})}
</Swiper>
</section>
<section className="mobile-quick-services" aria-label="快捷服务">
<QuickService
icon={<ShopbagOutline />}
label="我要租号"
hint="海量账号"
onClick={scrollToListings}
/>
<QuickService
to="/m/seller/listings/new"
icon={<AddCircleOutline />}
label="我要出号"
hint="闲置账号出租"
/>
<QuickService to="/m/crash" icon={<FlagOutline />} label="撞车商城" hint="装备与现货" />
<QuickService
icon={<GiftOutline />}
label={homeConfig.data?.aw_recycle.title || 'AW回收'}
hint="查看回收说明"
onClick={() => setEntryPreview(homeConfig.data?.aw_recycle ?? null)}
/>
<QuickService to="/m/messages" icon={<PhoneFill />} label="联系客服" hint="问题留痕" />
</section>
<div id="listings" className="mobile-market">
<div className="mobile-market__head">
<div>
<span className="mobile-eyebrow">CURATED ACCOUNTS</span>
<h2>本周柜选</h2>
<span>放心购</span>
<strong>严选账号</strong>
</div>
<span className="mobile-count">共 {data?.total ?? 0} 件</span>
<small>共 {totalListings} 个可租账号</small>
</div>
<div className="mobile-zone-tabs">
@@ -270,11 +571,15 @@ export function MobileHomePage() {
onClick={() => setZone(tab.key)}
>
{tab.label}
{typeof zoneCounts[tab.key] === 'number' ? <em>{zoneCounts[tab.key]}</em> : null}
</button>
))}
</div>
<div className="mobile-list-toolbar">
<button type="button" onClick={() => setSortOpen(true)}>
{sortLabel} <DownOutline />
</button>
<button
type="button"
onClick={() => {
@@ -282,74 +587,77 @@ export function MobileHomePage() {
setFilterOpen(true)
}}
>
筛选{activeCount > 0 ? ` (${activeCount})` : ''}
</button>
<button type="button" onClick={() => setSortOpen(true)}>
{sortLabel} <DownOutline />
筛选{activeCount > 0 ? ` ${activeCount}` : ''}
</button>
</div>
{isError ? (
{listings.isError ? (
<div className="mobile-error">
<ErrorBlock
status="disconnected"
title="商品没有加载出来"
description="后端服务可能暂时不可用,你的账号和订单不受影响。"
/>
<Button color="primary" size="small" onClick={() => void refetch()}>
<Button color="primary" size="small" onClick={() => void listings.refetch()}>
重试
</Button>
</div>
) : null}
<PullToRefresh onRefresh={() => refetch().then(() => undefined)}>
{isPending ? (
<PullToRefresh onRefresh={() => listings.refetch().then(() => undefined)}>
{listings.isPending ? (
<div className="mobile-skeleton-list">
{Array.from({ length: 3 }).map((_, index) => (
<div className="mobile-skeleton-card" key={index}>
<Skeleton animated className="mobile-skeleton-cover" />
<Skeleton.Paragraph lineCount={3} animated />
<Skeleton.Paragraph lineCount={4} animated />
</div>
))}
</div>
) : data?.items.length ? (
<div className="mobile-listing-list">
{data.items.map(item => (
<MobileListingCard item={item} key={item.id} />
))}
</div>
) : items.length ? (
<>
<div className="mobile-listing-list">
{items.map(item => (
<MobileListingCard item={item} key={item.id} />
))}
</div>
<div className="mobile-list-more">
{listings.hasNextPage ? (
<Button
block
fill="outline"
loading={listings.isFetchingNextPage}
onClick={() => void listings.fetchNextPage()}
>
加载更多账号
</Button>
) : (
<span>已经看到全部账号</span>
)}
</div>
</>
) : (
<div className="mobile-empty">
<Empty description="还没有符合条件的商品" />
<Button
size="small"
color="primary"
onClick={() => {
setKeyword('')
setSearchValue('')
setFilters(emptyListingFilters)
setZone('all')
setSort('')
}}
>
<Button size="small" color="primary" onClick={resetFilters}>
查看全部
</Button>
</div>
)}
</PullToRefresh>
</section>
</div>
<section className="mobile-rules">
<span className="mobile-eyebrow">RULES, IN PLAIN WORDS</span>
<h2>先把规则说明白</h2>
<p>押金、租金、额外消耗和退款规则都会在订单里逐项列出。结账备注不参与系统结算。</p>
<p>押金、租金、额外消耗和退款规则都会在订单里逐项列出。</p>
</section>
<Popup
visible={filterOpen}
onMaskClick={() => setFilterOpen(false)}
bodyStyle={{
height: '72vh',
height: '82vh',
overflowY: 'auto',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
@@ -358,58 +666,65 @@ export function MobileHomePage() {
<div className="mobile-filter-sheet">
<div className="mobile-filter-sheet__head">
<strong>筛选条件</strong>
<button type="button" onClick={resetDraft}>
<button type="button" onClick={() => setDraft(emptyListingFilters)}>
重置
</button>
</div>
<div className="mobile-filter-group">
<span className="mobile-filter-group__label">哈夫币</span>
<div className="mobile-filter-chips">
{coinRanges.map(option => (
<button
key={option.key}
type="button"
className={`mobile-filter-chip${draft.coinKey === option.key ? ' is-active' : ''}`}
onClick={() => toggleRange('coinKey', option.key)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="mobile-filter-group">
<span className="mobile-filter-group__label">租金</span>
<div className="mobile-filter-chips">
{priceRanges.map(option => (
<button
key={option.key}
type="button"
className={`mobile-filter-chip${draft.priceKey === option.key ? ' is-active' : ''}`}
onClick={() => toggleRange('priceKey', option.key)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="mobile-filter-group">
<span className="mobile-filter-group__label">比例</span>
<div className="mobile-filter-chips">
{ratioRanges.map(option => (
<button
key={option.key}
type="button"
className={`mobile-filter-chip${draft.ratioKey === option.key ? ' is-active' : ''}`}
onClick={() => toggleRange('ratioKey', option.key)}
>
{option.label}
</button>
))}
</div>
</div>
<MobileRangeGroup
label="哈夫币"
options={coinRanges}
activeKey={draft.coinKey}
onSelect={key => toggleRange('coinKey', key)}
/>
<MobileRangeGroup
label="租金"
options={priceRanges}
activeKey={draft.priceKey}
onSelect={key => toggleRange('priceKey', key)}
/>
<MobileRangeGroup
label="比例"
options={ratioRanges}
activeKey={draft.ratioKey}
onSelect={key => toggleRange('ratioKey', key)}
/>
<MobileRangeGroup
label="AWM"
options={awmRanges}
activeKey={draft.awmKey}
onSelect={key => toggleRange('awmKey', key)}
/>
<MobileRangeGroup
label="押金"
options={depositRanges}
activeKey={draft.depositKey}
onSelect={key => toggleRange('depositKey', key)}
/>
<MobileRangeGroup
label="合计金额"
options={totalRanges}
activeKey={draft.totalKey}
onSelect={key => toggleRange('totalKey', key)}
/>
<MobileRangeGroup
label="等级"
options={fireLevelRanges}
activeKey={draft.fireLevelKey}
onSelect={key => toggleRange('fireLevelKey', key)}
/>
<MobileRangeGroup
label="日消耗"
options={dailyLossRanges}
activeKey={draft.dailyLossKey}
onSelect={key => toggleRange('dailyLossKey', key)}
/>
<MobileRangeGroup
label="绝密KD"
options={secretKdRanges}
activeKey={draft.secretKdKey}
onSelect={key => toggleRange('secretKdKey', key)}
/>
<MobileChipGroup
label="保险"
@@ -435,6 +750,18 @@ export function MobileHomePage() {
selected={draft.loginMethod}
onToggle={option => toggleDraft('loginMethod', option)}
/>
<MobileChipGroup
label="段位"
options={rankOptions}
selected={draft.ranks}
onToggle={option => toggleDraft('ranks', option)}
/>
<MobileChipGroup
label="IP地区"
options={regionOptions}
selected={draft.regions}
onToggle={option => toggleDraft('regions', option)}
/>
{quickSkinGroups.map(group => (
<MobileChipGroup
key={group.key}
@@ -478,6 +805,24 @@ export function MobileHomePage() {
))}
</div>
</Popup>
<Popup
visible={Boolean(entryPreview)}
onMaskClick={() => setEntryPreview(null)}
bodyStyle={{ borderTopLeftRadius: 16, borderTopRightRadius: 16 }}
>
{entryPreview ? (
<div className="mobile-entry-preview">
<strong>{entryPreview.title}</strong>
<small>{entryPreview.subtitle}</small>
{entryPreview.image_url ? (
<img src={entryPreview.image_url} alt={entryPreview.title} />
) : (
<p>暂无更多说明</p>
)}
</div>
) : null}
</Popup>
</div>
)
}
@@ -1,7 +1,29 @@
import { Button, List, Tag } from 'antd-mobile'
import { useNavigate } from 'react-router-dom'
import type { ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Button, Dialog, Tag } from 'antd-mobile'
import {
AddCircleOutline,
BillOutline,
CheckCircleOutline,
ClockCircleOutline,
GiftOutline,
GlobalOutline,
MessageOutline,
PayCircleOutline,
RightOutline,
SetOutline,
ShopbagOutline,
StarOutline,
TruckOutline,
UserCircleOutline,
UserContactOutline,
VideoOutline,
} from 'antd-mobile-icons'
import { Link, useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { fetchWalletBalance } from '@/features/wallet/api'
import { formatMoney } from '@/shared/utils/format'
import { useSessionStore } from '@/stores/session'
const realnameLabel: Record<string, string> = {
@@ -10,61 +32,200 @@ const realnameLabel: Record<string, string> = {
rejected: '未通过',
}
interface ServiceItem {
label: string
icon: ReactNode
to: string
}
const buyerServices: ServiceItem[] = [
{ label: '全部订单', icon: <BillOutline />, to: '/m/orders' },
{ label: '待支付', icon: <ClockCircleOutline />, to: '/m/orders?tab=pending_payment' },
{ label: '待交接', icon: <TruckOutline />, to: '/m/orders?tab=pending_handoff' },
{ label: '使用中', icon: <VideoOutline />, to: '/m/orders?tab=renting' },
{ label: '已完成', icon: <CheckCircleOutline />, to: '/m/orders?tab=completed' },
{ label: '我的足迹', icon: <StarOutline />, to: '/m/listings/collections' },
]
const sellerServices: ServiceItem[] = [
{ label: '发布商品', icon: <AddCircleOutline />, to: '/m/seller/listings/new' },
{ label: '我的商品', icon: <ShopbagOutline />, to: '/m/seller/listings' },
{ label: '提现', icon: <PayCircleOutline />, to: '/m/wallet/withdrawal' },
{ label: '撞车商城', icon: <GiftOutline />, to: '/m/crash' },
]
const publicServices: ServiceItem[] = [
{ label: '消息中心', icon: <MessageOutline />, to: '/m/messages' },
{ label: '邀请好友', icon: <GiftOutline />, to: '/m/invitations' },
{ label: '公告中心', icon: <GlobalOutline />, to: '/m/announcements' },
{ label: '实名认证', icon: <UserContactOutline />, to: '/m/realname' },
]
function ServiceGrid({ items }: { items: ServiceItem[] }) {
return (
<div className="mobile-service-grid">
{items.map(item => (
<Link to={item.to} className="mobile-service-item" key={item.label}>
<span>{item.icon}</span>
<strong>{item.label}</strong>
</Link>
))}
</div>
)
}
export function MobileProfilePage() {
const navigate = useNavigate()
const user = useSessionStore(state => state.user)
const token = useSessionStore(state => state.token)
const logout = useSessionStore(state => state.logout)
const wallet = useQuery({
queryKey: ['wallet-balance', 'mobile-profile'],
queryFn: fetchWalletBalance,
enabled: Boolean(token),
})
const realnameStatus = user?.realname_status || 'unverified'
const realnameTone =
realnameStatus === 'verified' ? 'success' : realnameStatus === 'rejected' ? 'danger' : 'default'
const qualityOwner = user?.quality_owner_status === 'active'
const handleLogout = async () => {
const confirmed = await Dialog.confirm({
content: '确定退出当前账号吗?',
confirmText: '退出',
cancelText: '取消',
})
if (!confirmed) return
await logout()
navigate('/m')
}
return (
<div className="mobile-profile">
<section className="mobile-profile__head">
<BrandLogo height={42} variant="seal" />
<div>
<h1>{user ? user.nickname || '我的商行' : '还没登录'}</h1>
<p>{user ? user.phone : '登录后查看订单、钱包与实名状态'}</p>
<section className={`mobile-profile-hero${user ? '' : ' is-guest'}`}>
<div className="mobile-profile-hero__top">
<div className="mobile-profile-avatar">
{user ? user.nickname?.slice(0, 1) || user.phone.slice(-1) : <UserCircleOutline />}
</div>
<div className="mobile-profile-identity">
<div>
<h1>{user ? user.nickname || `用户${user.phone.slice(-6)}` : '立即登录'}</h1>
{user ? (
<Tag color={realnameStatus === 'verified' ? 'success' : 'default'} fill="outline">
{realnameLabel[realnameStatus] || '未实名'}
</Tag>
) : null}
{qualityOwner ? <Tag color="warning">优质号主</Tag> : null}
</div>
<p>
{user
? `ID: ${user.id} · ${user.phone.slice(0, 3)}****${user.phone.slice(-4)}`
: '登录后查看订单、余额与账号收益'}
</p>
</div>
{user ? (
<button
type="button"
className="mobile-profile-settings"
onClick={() => void handleLogout()}
>
<SetOutline />
</button>
) : null}
</div>
</section>
<List className="mobile-profile__list">
<List.Item extra={user ? '已登录' : '未登录'}>账号状态</List.Item>
<List.Item
clickable
onClick={() => navigate(user ? '/m/realname' : '/m/login?redirect=/m/realname')}
extra={<Tag color={realnameTone}>{realnameLabel[realnameStatus] ?? '未实名'}</Tag>}
>
实名认证
</List.Item>
<List.Item onClick={() => navigate('/m/orders')}>我的订单</List.Item>
<List.Item onClick={() => navigate('/m/messages')}>消息中心</List.Item>
<List.Item
onClick={() =>
navigate(user ? '/m/seller/listings' : '/m/login?redirect=/m/seller/listings')
}
>
卖家中心
</List.Item>
</List>
<div className="mobile-profile__actions">
{user ? (
<Button
block
onClick={async () => {
await logout()
navigate('/m')
}}
>
退出登录
</Button>
<div className="mobile-profile-assets">
<div>
<span>账户余额</span>
<strong>
{wallet.data ? formatMoney(wallet.data.available_balance_cent) : '加载中'}
</strong>
</div>
<div>
<span>可用积分</span>
<strong>{user.points_available ?? 0}</strong>
</div>
<button type="button" onClick={() => navigate('/m/wallet/withdrawal')}>
提现
</button>
</div>
) : (
<Button block color="primary" onClick={() => navigate('/m/login')}>
登录 / 注册
</Button>
)}
</div>
</section>
<section className="mobile-primary-actions">
<Link to="/m" className="mobile-primary-action is-rent">
<span>
<ShopbagOutline />
</span>
<div>
<strong>我要租号</strong>
<small>海量账号任你挑选</small>
</div>
<RightOutline />
</Link>
<Link to="/m/seller/listings/new" className="mobile-primary-action is-sell">
<span>
<AddCircleOutline />
</span>
<div>
<strong>我要出号</strong>
<small>闲置账号快速出租</small>
</div>
<RightOutline />
</Link>
</section>
<section className="mobile-profile-section">
<div className="mobile-profile-section__head">
<div>
<span>买家中心</span>
<strong>我的租用</strong>
</div>
<Link to="/m/orders">
全部订单 <RightOutline />
</Link>
</div>
<ServiceGrid items={buyerServices} />
</section>
<section className="mobile-profile-section">
<div className="mobile-profile-section__head">
<div>
<span>卖家中心</span>
<strong>账号出租</strong>
</div>
<Link to="/m/seller/listings">
商品管理 <RightOutline />
</Link>
</div>
<ServiceGrid items={sellerServices} />
</section>
<section className="mobile-profile-section">
<div className="mobile-profile-section__head">
<div>
<span>公共服务</span>
<strong>账户与服务</strong>
</div>
</div>
<ServiceGrid items={publicServices} />
</section>
{user ? (
<section className="mobile-profile-account">
<BrandLogo height={28} />
<div>
<strong>{user.nickname || '解忧商行用户'}</strong>
<span>成长等级:{user.renter_growth_level_name || '普通用户'}</span>
</div>
<Button size="small" fill="none" onClick={() => void handleLogout()}>
退出
</Button>
</section>
) : null}
</div>
)
}
+65 -36
View File
@@ -1,8 +1,13 @@
import 'antd-mobile/es/global'
import { TabBar } from 'antd-mobile'
import { AppOutline, MessageOutline, UnorderedListOutline, UserOutline } from 'antd-mobile-icons'
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
import {
AddOutline,
AppOutline,
MessageOutline,
UnorderedListOutline,
UserOutline,
} from 'antd-mobile-icons'
import { Link, Outlet, useLocation } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { useChatUnreadCount } from '@/features/messages/api'
@@ -11,63 +16,87 @@ import { useSessionStore } from '@/stores/session'
const tabs = [
{ key: '/m', title: '首页', icon: <AppOutline /> },
{ key: '/m/orders', title: '订单', icon: <UnorderedListOutline /> },
{ key: '/m/seller/listings/new', title: '发布', icon: <AddOutline />, primary: true },
{ key: '/m/messages', title: '消息', icon: <MessageOutline /> },
{ key: '/m/profile', title: '我的', icon: <UserOutline /> },
]
function activeTab(pathname: string) {
if (pathname.startsWith('/m/orders') || pathname.startsWith('/m/chats')) return '/m/orders'
if (pathname.startsWith('/m/messages')) return '/m/messages'
if (pathname.startsWith('/m/orders') || pathname.startsWith('/m/crash/orders')) {
return '/m/orders'
}
if (pathname.startsWith('/m/messages') || pathname.startsWith('/m/chats')) {
return '/m/messages'
}
if (pathname.startsWith('/m/profile')) return '/m/profile'
if (pathname.startsWith('/m/seller/listings/new')) return '/m/seller/listings/new'
return '/m'
}
export function MobileLayout() {
const location = useLocation()
const navigate = useNavigate()
const user = useSessionStore(state => state.user)
const chatUnread = useChatUnreadCount(Boolean(user))
const unreadBadge = chatUnread.data && chatUnread.data > 0 ? chatUnread.data : undefined
const hideTabbar =
const unreadCount = chatUnread.data ?? 0
const active = activeTab(location.pathname)
const hideChrome =
/^\/m\/listings\/[^/]+$/.test(location.pathname) ||
/^\/m\/orders\/[^/]+$/.test(location.pathname) ||
/^\/m\/messages\/[^/]+$/.test(location.pathname)
/^\/m\/messages\/[^/]+$/.test(location.pathname) ||
/^\/m\/crash\/[^/]+$/.test(location.pathname) ||
location.pathname === '/m/login' ||
location.pathname === '/m/register' ||
location.pathname === '/m/add-home-guide'
return (
<div className={`mobile-shell${hideTabbar ? ' mobile-shell--no-tabbar' : ''}`}>
<header className="mobile-header">
<Link to="/m" className="mobile-header__brand" aria-label="解忧商行移动端首页">
<BrandLogo height={30} />
</Link>
<div className="mobile-header__right">
{user ? (
<Link to="/m/profile" className="mobile-header__user">
{user.nickname || '我的'}
<div className={`mobile-shell${hideChrome ? ' mobile-shell--detail' : ''}`}>
{hideChrome ? null : (
<header className="mobile-header">
<Link to="/m" className="mobile-header__brand" aria-label="解忧商行移动端首页">
<BrandLogo height={30} />
</Link>
<div className="mobile-header__right">
<Link to="/m/messages" className="mobile-header__icon" aria-label="消息中心">
<MessageOutline />
{unreadCount > 0 ? (
<em className="mobile-header__badge">{unreadCount > 99 ? '99+' : unreadCount}</em>
) : null}
</Link>
) : (
<Link to="/m/login" className="mobile-header__login">
登录
<Link to={user ? '/m/profile' : '/m/login'} className="mobile-header__user">
{user ? user.nickname || '我的' : '登录'}
</Link>
)}
</div>
</header>
</div>
</header>
)}
<main className="mobile-content">
<Outlet />
</main>
<div className="mobile-tabbar" hidden={hideTabbar}>
<TabBar activeKey={activeTab(location.pathname)} onChange={key => navigate(key)} safeArea>
{tabs.map(tab => (
<TabBar.Item
key={tab.key}
icon={tab.icon}
title={tab.title}
badge={tab.key === '/m/messages' ? unreadBadge : undefined}
/>
))}
</TabBar>
</div>
{hideChrome ? null : (
<nav className="mobile-tabbar" aria-label="移动端主导航">
{tabs.map(tab => {
const selected = active === tab.key
return (
<Link
key={tab.key}
to={tab.key}
className={`mobile-tab${selected ? ' is-active' : ''}${
tab.primary ? ' is-primary' : ''
}`}
>
<span className="mobile-tab__icon">
{tab.icon}
{tab.key === '/m/messages' && unreadCount > 0 ? (
<em className="mobile-tab__badge">{unreadCount > 99 ? '99+' : unreadCount}</em>
) : null}
</span>
<span className="mobile-tab__title">{tab.title}</span>
</Link>
)
})}
</nav>
)}
</div>
)
}
+10
View File
@@ -13,6 +13,12 @@ export interface AuthUser {
risk_status?: string
credit_score?: number
status?: string
renter_growth_level?: string
renter_growth_level_name?: string
renter_growth_points?: number
points_available?: number
quality_owner_status?: string
deposit_free_quota_cent?: number
}
export interface TokenPair {
@@ -46,6 +52,10 @@ export interface Listing {
price_cent: number
deposit_amount_cent: number
is_accelerated_sale?: boolean
is_premium?: boolean
owner_is_quality_owner?: boolean
compensation_plan?: string
insurance_fee_bps?: number
in_transaction: boolean
status: string
review_status: string
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { getMobilePath } from './mobileHost'
describe('getMobilePath', () => {
it('maps storefront routes to the dedicated mobile tree', () => {
expect(getMobilePath('/')).toBe('/m')
expect(getMobilePath('/listings/12')).toBe('/m/listings/12')
expect(getMobilePath('/orders/9')).toBe('/m/orders/9')
expect(getMobilePath('/messages/3')).toBe('/m/messages/3')
expect(getMobilePath('/seller/listings/new')).toBe('/m/seller/listings/new')
expect(getMobilePath('/i/ABC123')).toBe('/m/i/ABC123')
})
it('keeps mobile and admin routes unchanged', () => {
expect(getMobilePath('/m/profile')).toBe('/m/profile')
expect(getMobilePath('/console-jy8k3m/users')).toBe('/console-jy8k3m/users')
})
it('does not invent mobile pages for unsupported static pages', () => {
expect(getMobilePath('/rules')).toBe('/rules')
})
})
+51
View File
@@ -0,0 +1,51 @@
import { getAdminBasePath } from '@/shared/api/adminClient'
export const DESKTOP_PREFERENCE_KEY = 'jieyou:prefer-desktop'
const mobileUserAgentPattern =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i
export function isMobileDevice() {
if (typeof window === 'undefined') return false
return mobileUserAgentPattern.test(window.navigator.userAgent) || window.innerWidth < 768
}
export function prefersDesktopSite() {
if (typeof window === 'undefined') return false
return window.sessionStorage.getItem(DESKTOP_PREFERENCE_KEY) === '1'
}
export function setDesktopPreference(enabled: boolean) {
if (typeof window === 'undefined') return
if (enabled) {
window.sessionStorage.setItem(DESKTOP_PREFERENCE_KEY, '1')
return
}
window.sessionStorage.removeItem(DESKTOP_PREFERENCE_KEY)
}
export function getMobilePath(path: string): string {
if (path === '/m' || path.startsWith('/m/')) return path
const adminBase = getAdminBasePath()
if (path === adminBase || path.startsWith(`${adminBase}/`)) return path
if (path === '/') return '/m'
if (path === '/login') return '/m/login'
if (path === '/register') return '/m/register'
if (path === '/realname') return '/m/realname'
if (path === '/notifications') return '/m/notifications'
if (path === '/invitations') return '/m/invitations'
if (path === '/messages' || path.startsWith('/messages/')) return `/m${path}`
if (path === '/announcements' || path.startsWith('/announcements/')) return `/m${path}`
if (path === '/profile' || path === '/wallet') return '/m/profile'
if (path === '/wallet/withdrawal') return '/m/wallet/withdrawal'
if (path === '/listings' || path.startsWith('/listings/')) return `/m${path}`
if (path === '/orders' || path.startsWith('/orders/')) return `/m${path}`
if (path === '/crash' || path.startsWith('/crash/')) return `/m${path}`
if (path.startsWith('/seller/listings')) return `/m${path}`
if (path.startsWith('/i/')) return `/m${path}`
return path
}
+1
View File
@@ -29,3 +29,4 @@
@import './seller.css';
@import './mobile-seller.css';
@import './components.css';
@import './mobile-refresh.css';
File diff suppressed because it is too large Load Diff