feat(mobile): 新增解忧商行移动端首页、详情与个人中心
This commit is contained in:
@@ -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 : '登录没有完成,请核对手机号和密码'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user