feat(mobile): 新增解忧商行移动端首页、详情与个人中心

This commit is contained in:
yml2213
2026-10-30 12:26:31 +08:00
parent 97167fa482
commit ef01486e53
14 changed files with 1499 additions and 10 deletions
+2 -1
View File
@@ -29,7 +29,8 @@ export function LoginPage() {
await login(values.phone.trim(), values.password)
message.success('登录成功')
const redirect = searchParams.get('redirect')
navigate(redirect && redirect.startsWith('/') ? redirect : '/', { replace: true })
const fallbackPath = window.location.pathname.startsWith('/m') ? '/m' : '/'
navigate(redirect && redirect.startsWith('/') ? redirect : fallbackPath, { replace: true })
} catch (err) {
const text =
err instanceof Error && err.message ? err.message : '登录没有完成,请核对手机号和密码'
+13 -1
View File
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { apiClient, unwrapData } from '@/shared/api/client'
import type { ApiResponse, PublicListingPage } from '@/shared/api/types'
import type { ApiResponse, Listing, PublicListingPage } from '@/shared/api/types'
export interface ListingQuery {
page?: number
@@ -30,3 +30,15 @@ export function useListings(query: ListingQuery) {
queryFn: () => fetchListings(query),
})
}
export function fetchListingDetail(id: number | string) {
return unwrapData<Listing>(apiClient.get<ApiResponse<Listing>>(`/listings/${id}`))
}
export function useListingDetail(id?: string) {
return useQuery({
queryKey: ['listing-detail', id],
queryFn: () => fetchListingDetail(id as string),
enabled: Boolean(id),
})
}
@@ -0,0 +1,166 @@
import { useState } from 'react'
import { Button, Empty, ErrorBlock, PullToRefresh, SearchBar, Skeleton, Tag } from 'antd-mobile'
import { RightOutline, SearchOutline } from 'antd-mobile-icons'
import { Link } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { useListings } from '@/features/listings/api'
import type { Listing } from '@/shared/api/types'
const quickFilters = [
{ label: '全部', keyword: '' },
{ label: '国服', keyword: '国服' },
{ label: '高资产', keyword: '高资产' },
{ label: '可租', keyword: '' },
]
function money(cents: number) {
return `¥${(cents / 100).toFixed(2)}`
}
function MobileListingCard({ item }: { item: Listing }) {
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" />
) : (
<BrandLogo height={38} variant="seal" />
)}
</div>
<div className="mobile-listing-card__body">
<div className="mobile-listing-card__top">
<span className="mobile-listing-card__no">{item.listing_no}</span>
<Tag color={item.in_transaction ? 'default' : 'success'} fill="outline">
{item.in_transaction ? '租赁中' : '可租'}
</Tag>
</div>
<h3>{item.title}</h3>
<div className="mobile-listing-card__tags">
<span>{item.server_region || '区服待定'}</span>
<span>{item.rank_level || '段位待定'}</span>
<span>哈夫币 {item.haf_coin_amount.toLocaleString()}</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>
</div>
</div>
</Link>
)
}
export function MobileHomePage() {
const [keyword, setKeyword] = useState('')
const [searchValue, setSearchValue] = useState('')
const { data, isPending, isError, refetch } = useListings({
page: 1,
page_size: 20,
keyword,
})
const applyFilter = (value: string) => {
setKeyword(value)
setSearchValue(value)
}
return (
<div className="mobile-home">
<section className="mobile-hero">
<div className="mobile-hero__eyebrow">JIEYOU TRADING HOUSE</div>
<h1>
租得明白,
<br />
还得安心。
</h1>
<p>实名审核 · 流程留痕 · 账单逐项列明</p>
<SearchBar
className="mobile-search"
value={searchValue}
onChange={setSearchValue}
onSearch={value => setKeyword(value.trim())}
placeholder="搜索区服、段位、装备或商品号"
searchIcon={<SearchOutline />}
/>
<div className="mobile-chips">
{quickFilters.map(item => (
<button
type="button"
key={item.label}
className={keyword === item.keyword && item.label !== '全部' ? 'is-active' : ''}
onClick={() => applyFilter(item.keyword)}
>
{item.label}
</button>
))}
</div>
</section>
<section className="mobile-trust">
<span>实名审核</span>
<span>交接留痕</span>
<span>争议可申诉</span>
</section>
<section className="mobile-list-section">
<div className="mobile-section-head">
<div>
<span className="mobile-eyebrow">CURATED ACCOUNTS</span>
<h2>本周柜选</h2>
</div>
<span className="mobile-count">共 {data?.total ?? 0} 件</span>
</div>
{isError ? (
<div className="mobile-error">
<ErrorBlock
status="disconnected"
title="商品没有加载出来"
description="后端服务可能暂时不可用,你的账号和订单不受影响。"
/>
<Button color="primary" size="small" onClick={() => void refetch()}>
重试
</Button>
</div>
) : null}
<PullToRefresh onRefresh={() => refetch().then(() => undefined)}>
{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 />
</div>
))}
</div>
) : data?.items.length ? (
<div className="mobile-listing-list">
{data.items.map(item => (
<MobileListingCard item={item} key={item.id} />
))}
</div>
) : (
<div className="mobile-empty">
<Empty description="还没有符合条件的商品" />
<Button size="small" color="primary" onClick={() => applyFilter('')}>
查看全部
</Button>
</div>
)}
</PullToRefresh>
</section>
<section className="mobile-rules">
<span className="mobile-eyebrow">RULES, IN PLAIN WORDS</span>
<h2>先把规则说明白</h2>
<p>押金、租金、额外消耗和退款规则都会在订单里逐项列出。结账备注不参与系统结算。</p>
</section>
</div>
)
}
@@ -0,0 +1,155 @@
import { Button, ErrorBlock, Skeleton, Tag, Toast } from 'antd-mobile'
import { LeftOutline, PhoneFill } from 'antd-mobile-icons'
import { useNavigate, useParams } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { useListingDetail } from '@/features/listings/api'
function money(cents: number) {
return `¥${(cents / 100).toFixed(2)}`
}
function assetValue(value: unknown) {
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>
if (typeof record.start === 'string' && typeof record.end === 'string') {
return `${record.start} – ${record.end}`
}
return Object.entries(record)
.map(([key, item]) => `${key}:${String(item)}`)
.join(',')
}
if (Array.isArray(value)) return value.map(String).join('、')
return value == null || value === '' ? '未填写' : String(value)
}
export function MobileListingDetailPage() {
const navigate = useNavigate()
const { id } = useParams()
const { data, isPending, isError, refetch } = useListingDetail(id)
if (isPending) {
return (
<div className="mobile-detail mobile-detail--loading">
<Skeleton animated className="mobile-detail__cover-skeleton" />
<Skeleton.Paragraph lineCount={6} animated />
</div>
)
}
if (isError || !data) {
return (
<div className="mobile-detail mobile-detail--error">
<ErrorBlock status="disconnected" title="商品没有加载出来" description="请返回列表后重试。" />
<Button color="primary" onClick={() => void refetch()}>
重试
</Button>
</div>
)
}
const onlineTime =
data.asset_summary && typeof data.asset_summary === 'object'
? (data.asset_summary.online_time as unknown)
: undefined
const assetEntries = Object.entries(data.asset_summary || {}).filter(
([key]) => key !== 'online_time'
)
return (
<div className="mobile-detail">
<button type="button" className="mobile-detail__back" onClick={() => navigate(-1)}>
<LeftOutline /> 返回
</button>
<div className="mobile-detail__cover">
{data.cover_url ? (
<img src={data.cover_url} alt={data.title} />
) : (
<BrandLogo height={52} variant="seal" />
)}
</div>
<section className="mobile-detail__head">
<div className="mobile-detail__no">{data.listing_no}</div>
<h1>{data.title}</h1>
<div className="mobile-detail__tags">
<Tag color={data.in_transaction ? 'default' : 'success'} fill="outline">
{data.in_transaction ? '租赁中' : '可租'}
</Tag>
<Tag fill="outline">{data.server_region || '区服待定'}</Tag>
<Tag fill="outline">{data.login_platform || '登录方式待定'}</Tag>
<Tag fill="outline">{data.rank_level || '段位待定'}</Tag>
</div>
</section>
<section className="mobile-detail__price-card">
<div>
<span>租金</span>
<strong>{money(data.price_cent)}</strong>
</div>
<div>
<span>押金</span>
<strong>{money(data.deposit_amount_cent)}</strong>
</div>
<div>
<span>哈夫币</span>
<strong>{data.haf_coin_amount.toLocaleString()}</strong>
</div>
</section>
<section className="mobile-detail__section">
<h2>账号说明</h2>
<p>{data.description || '号主暂未填写补充说明。'}</p>
</section>
<section className="mobile-detail__section">
<h2>资产摘要</h2>
<dl className="mobile-detail__assets">
{assetEntries.length ? (
assetEntries.map(([key, value]) => (
<div key={key}>
<dt>{key}</dt>
<dd>{assetValue(value)}</dd>
</div>
))
) : (
<div>
<dt>资产</dt>
<dd>未填写</dd>
</div>
)}
<div>
<dt>在线时间</dt>
<dd>{assetValue(onlineTime)}</dd>
</div>
</dl>
</section>
<section className="mobile-detail__notice">
<strong>先看清规则,再决定要不要租</strong>
<p>押金、租金、额外消耗和退款规则会在下单页逐项列出;交接、归还、结账全程留痕。</p>
</section>
<div className="mobile-detail__actions">
<Button
block
onClick={() => {
Toast.show({ content: '客服接入将在下一批完成' })
}}
>
<PhoneFill /> 联系客服
</Button>
<Button
block
color="primary"
onClick={() => {
Toast.show({ content: '下单流程将在下一批接入' })
}}
>
确认租赁
</Button>
</div>
</div>
)
}
@@ -0,0 +1,26 @@
import { Button } from 'antd-mobile'
import { useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
export function MobilePlaceholderPage({
title,
description,
}: {
title: string
description: string
}) {
const navigate = useNavigate()
return (
<div className="mobile-placeholder">
<BrandLogo height={50} variant="seal" />
<span className="mobile-eyebrow">PHASE 1 · COMING NEXT</span>
<h1>{title}</h1>
<p>{description}</p>
<Button color="primary" onClick={() => navigate('/m')}>
回柜选看看
</Button>
</div>
)
}
@@ -0,0 +1,50 @@
import { Button, List } from 'antd-mobile'
import { useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { useSessionStore } from '@/stores/session'
export function MobileProfilePage() {
const navigate = useNavigate()
const user = useSessionStore(state => state.user)
const logout = useSessionStore(state => state.logout)
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>
</div>
</section>
<List className="mobile-profile__list">
<List.Item extra={user ? '已登录' : '未登录'}>账号状态</List.Item>
<List.Item extra={user?.realname_status === 'verified' ? '已实名' : '未实名'}>
实名认证
</List.Item>
<List.Item onClick={() => navigate('/m/orders')}>我的订单</List.Item>
<List.Item onClick={() => navigate('/m/messages')}>消息中心</List.Item>
</List>
<div className="mobile-profile__actions">
{user ? (
<Button
block
onClick={async () => {
await logout()
navigate('/m')
}}
>
退出登录
</Button>
) : (
<Button block color="primary" onClick={() => navigate('/m/login')}>
登录 / 注册
</Button>
)}
</div>
</div>
)
}