feat(mobile): 重构移动端首页与个人中心
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user