feat(storefront): 新增 PC 商品详情与下单确认
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Image,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
ShoppingOutlined,
|
||||
WalletOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import { BrandLogo } from '@/components/BrandLogo'
|
||||
import { useListingDetail } from '@/features/listings/api'
|
||||
import {
|
||||
createOrder,
|
||||
useOrderAgreements,
|
||||
usePostRentalNotice,
|
||||
} from '@/features/orders/api'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { Listing } from '@/shared/api/types'
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function galleryImages(item: Listing) {
|
||||
const images = [item.cover_url, ...(item.screenshot_urls ?? [])].filter(Boolean)
|
||||
return images.length > 0 ? Array.from(new Set(images)) : []
|
||||
}
|
||||
|
||||
export function ListingDetailPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { modal } = App.useApp()
|
||||
const { data, isPending, isError, refetch } = useListingDetail(id)
|
||||
const user = useSessionStore(state => state.user)
|
||||
const token = useSessionStore(state => state.token)
|
||||
const agreements = useOrderAgreements()
|
||||
const postRentalNotice = usePostRentalNotice()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [agreementOpen, setAgreementOpen] = useState(false)
|
||||
const [agreementTitle, setAgreementTitle] = useState('')
|
||||
const [agreementContent, setAgreementContent] = useState('')
|
||||
|
||||
const showAgreement = (title: string, content?: string) => {
|
||||
setAgreementTitle(title)
|
||||
setAgreementContent(content || '暂无内容')
|
||||
setAgreementOpen(true)
|
||||
}
|
||||
|
||||
const openConfirm = (item: Listing) => {
|
||||
if (!token) {
|
||||
navigate(`/login?redirect=/listings/${item.id}`)
|
||||
return
|
||||
}
|
||||
if (user?.id === item.owner_id) {
|
||||
modal.warning({ title: '不能租用自己的商品', content: '请选择其他号主的账号。' })
|
||||
return
|
||||
}
|
||||
if (item.in_transaction) {
|
||||
modal.warning({ title: '商品正在租赁中', content: '可以稍后再来看看,或先浏览其他柜选。' })
|
||||
return
|
||||
}
|
||||
if (user?.realname_status !== 'verified') {
|
||||
modal.warning({
|
||||
title: '需要先完成实名认证',
|
||||
content: '当前账号未完成实名认证,无法创建租赁订单。请先完成实名认证后再下单。',
|
||||
})
|
||||
return
|
||||
}
|
||||
setAgreed(false)
|
||||
setConfirmOpen(true)
|
||||
}
|
||||
|
||||
const submitOrder = async () => {
|
||||
if (!data) return
|
||||
if (!agreed) {
|
||||
modal.warning({ title: '请先阅读并同意协议', content: '勾选协议后才能提交订单。' })
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const order = await createOrder(data.id)
|
||||
setConfirmOpen(false)
|
||||
modal.success({
|
||||
title: '订单已创建',
|
||||
content: '订单已生成,接下来请完成支付;支付成功后才进入交接流程。',
|
||||
okText: '去支付',
|
||||
onOk: () => navigate(`/m/orders/${order.id}`),
|
||||
})
|
||||
} catch (error) {
|
||||
modal.error({
|
||||
title: '下单失败',
|
||||
content: error instanceof Error ? error.message : '请稍后再试。',
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="page-section">
|
||||
<Card loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="page-section">
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="商品没有加载出来"
|
||||
description="商品可能已下架,或后端服务暂时不可用。"
|
||||
action={
|
||||
<Button size="small" onClick={() => void refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const images = galleryImages(data)
|
||||
const assetEntries = Object.entries(data.asset_summary ?? {}).filter(
|
||||
([key]) => key !== 'online_time'
|
||||
)
|
||||
const onlineTime = data.asset_summary?.online_time
|
||||
const isOwner = user?.id === data.owner_id
|
||||
|
||||
return (
|
||||
<div className="listing-detail-page">
|
||||
<div className="listing-detail-page__crumb">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ title: <Link to="/">首页</Link> },
|
||||
{ title: <Link to="/#listings">柜选</Link> },
|
||||
{ title: data.listing_no },
|
||||
]}
|
||||
/>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)}>
|
||||
返回
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Row gutter={[24, 24]} className="listing-detail-page__hero">
|
||||
<Col xs={24} lg={14}>
|
||||
<div className="listing-detail-gallery">
|
||||
{images.length > 0 ? (
|
||||
<Image.PreviewGroup>
|
||||
<div className="listing-detail-gallery__main">
|
||||
<Image src={images[0]} alt={data.title} />
|
||||
</div>
|
||||
{images.length > 1 ? (
|
||||
<div className="listing-detail-gallery__thumbs">
|
||||
{images.slice(0, 6).map(url => (
|
||||
<Image key={url} src={url} alt={data.title} width={92} height={68} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<div className="listing-detail-gallery__empty">
|
||||
<BrandLogo height={40} />
|
||||
<span>暂无商品图片</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={10}>
|
||||
<Card className="listing-buy-card">
|
||||
<div className="listing-buy-card__top">
|
||||
<Typography.Text className="listing-card__no">{data.listing_no}</Typography.Text>
|
||||
<Tag color={data.in_transaction ? 'default' : 'green'} bordered={false}>
|
||||
{data.in_transaction ? '租赁中' : '可租'}
|
||||
</Tag>
|
||||
</div>
|
||||
<Typography.Title level={2} className="brand-title listing-buy-card__title">
|
||||
{data.title}
|
||||
</Typography.Title>
|
||||
<Space size={6} wrap>
|
||||
<Tag bordered={false}>{data.server_region || '区服待定'}</Tag>
|
||||
<Tag bordered={false}>{data.login_platform || '登录方式待定'}</Tag>
|
||||
{data.rank_level ? <Tag bordered={false}>{data.rank_level}</Tag> : null}
|
||||
</Space>
|
||||
|
||||
<div className="listing-buy-card__price">
|
||||
<span>租金</span>
|
||||
<strong>{money(data.price_cent)}</strong>
|
||||
</div>
|
||||
<div className="listing-buy-card__meta">
|
||||
<div>
|
||||
<span>押金</span>
|
||||
<strong>{money(data.deposit_amount_cent)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>哈夫币</span>
|
||||
<strong>{data.haf_coin_amount.toLocaleString()} M</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>号主</span>
|
||||
<strong>{data.owner_nickname || `#${data.owner_id}`}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="listing-buy-card__notice"
|
||||
message="先看清规则,再决定要不要租"
|
||||
description="提交订单后会锁定商品;支付成功才进入交接,租金、押金、消耗与退款均按订单账单结算。"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
disabled={isOwner || data.in_transaction}
|
||||
onClick={() => openConfirm(data)}
|
||||
>
|
||||
{isOwner ? '不能租用自己发布的商品' : data.in_transaction ? '商品租赁中' : '确认租赁'}
|
||||
</Button>
|
||||
<div className="listing-buy-card__links">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showAgreement(
|
||||
agreements.data?.virtual_asset_purchase?.title ?? '虚拟资产购买协议',
|
||||
agreements.data?.virtual_asset_purchase?.content
|
||||
)
|
||||
}
|
||||
>
|
||||
《虚拟资产购买协议》
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showAgreement(
|
||||
agreements.data?.renter_agreement?.title ?? '租客协议',
|
||||
agreements.data?.renter_agreement?.content
|
||||
)
|
||||
}
|
||||
>
|
||||
《租客协议》
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
showAgreement(
|
||||
postRentalNotice.data?.title ?? '租后须知',
|
||||
postRentalNotice.data?.content
|
||||
)
|
||||
}
|
||||
>
|
||||
《租后须知》
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[24, 24]} className="listing-detail-page__body">
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="账号说明" className="listing-detail-panel">
|
||||
<Typography.Paragraph className="listing-detail-description">
|
||||
{data.description || '号主暂未填写补充说明。'}
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
|
||||
<Card title="资产摘要" className="listing-detail-panel">
|
||||
<Descriptions column={2} size="small" colon={false}>
|
||||
{assetEntries.map(([key, value]) => (
|
||||
<Descriptions.Item key={key} label={key}>
|
||||
{assetValue(value)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
<Descriptions.Item label="在线时间">{assetValue(onlineTime)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="交易流程" className="listing-detail-panel">
|
||||
<div className="listing-process">
|
||||
{[
|
||||
['01', '确认下单', '核对租金、押金与账号信息,提交订单。'],
|
||||
['02', '完成支付', '支付成功后订单进入待交接。'],
|
||||
['03', '号主交接', '号主提交账号与登录说明,租客确认收号。'],
|
||||
['04', '使用与归还', '租期内按规则使用,结束时发起结账。'],
|
||||
['05', '确认结算', '双方确认或客服仲裁,完成押金与租金结算。'],
|
||||
].map(([no, title, desc]) => (
|
||||
<div key={no} className="listing-process__step">
|
||||
<span>{no}</span>
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<p>{desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={8}>
|
||||
<Card className="listing-trust-card">
|
||||
<div className="listing-trust-card__item">
|
||||
<SafetyCertificateOutlined />
|
||||
<div>
|
||||
<strong>实名与审核</strong>
|
||||
<span>发布、下单前均需身份核验。</span>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="listing-trust-card__item">
|
||||
<ShoppingOutlined />
|
||||
<div>
|
||||
<strong>全程留痕</strong>
|
||||
<span>交接、归还、结账都有记录。</span>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="listing-trust-card__item">
|
||||
<WalletOutlined />
|
||||
<div>
|
||||
<strong>账单透明</strong>
|
||||
<span>租金、押金、消耗逐项列明。</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
open={confirmOpen}
|
||||
title="确认租赁"
|
||||
width={620}
|
||||
onCancel={() => {
|
||||
if (!submitting) setConfirmOpen(false)
|
||||
}}
|
||||
maskClosable={!submitting}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={() => setConfirmOpen(false)} disabled={submitting}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" loading={submitting} onClick={() => void submitOrder()}>
|
||||
提交订单
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Descriptions column={2} size="small" bordered>
|
||||
<Descriptions.Item label="商品" span={2}>
|
||||
{data.title}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="租金">{money(data.price_cent)}</Descriptions.Item>
|
||||
<Descriptions.Item label="押金">{money(data.deposit_amount_cent)}</Descriptions.Item>
|
||||
<Descriptions.Item label="哈夫币">
|
||||
{data.haf_coin_amount.toLocaleString()} M
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="号主">
|
||||
{data.owner_nickname || `#${data.owner_id}`}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="listing-buy-card__notice"
|
||||
message="提交后会锁定商品并生成待支付订单"
|
||||
description="请在支付前再次核对金额与账号信息;支付成功后才进入交接流程。"
|
||||
/>
|
||||
<Checkbox checked={agreed} onChange={event => setAgreed(event.target.checked)}>
|
||||
我已阅读并同意《虚拟资产购买协议》《租客协议》和《租后须知》
|
||||
</Checkbox>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={agreementOpen}
|
||||
title={agreementTitle}
|
||||
width={760}
|
||||
zIndex={1200}
|
||||
footer={
|
||||
<Button type="primary" onClick={() => setAgreementOpen(false)}>
|
||||
知道了
|
||||
</Button>
|
||||
}
|
||||
onCancel={() => setAgreementOpen(false)}
|
||||
>
|
||||
<div className="listing-agreement-content">{agreementContent}</div>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { BrandLogo } from '@/components/BrandLogo'
|
||||
import { useListings } from '@/features/listings/api'
|
||||
import type { Listing } from '@/shared/api/types'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
const PAGE_SIZE = 12
|
||||
|
||||
@@ -32,33 +33,35 @@ function money(cents: number) {
|
||||
|
||||
function ListingCard({ item }: { item: Listing }) {
|
||||
return (
|
||||
<Card className="listing-card" hoverable cover={<ListingCover item={item} />}>
|
||||
<div className="listing-card__head">
|
||||
<Typography.Text className="listing-card__no">{item.listing_no}</Typography.Text>
|
||||
<Tag color={item.in_transaction ? 'default' : 'green'} bordered={false}>
|
||||
{item.in_transaction ? '租赁中' : '可租'}
|
||||
</Tag>
|
||||
</div>
|
||||
<Typography.Title level={4} className="listing-card__title" ellipsis={{ rows: 2 }}>
|
||||
{item.title}
|
||||
</Typography.Title>
|
||||
<div className="listing-card__tags">
|
||||
<Tag bordered={false}>{item.server_region || '区服待定'}</Tag>
|
||||
<Tag bordered={false}>{item.login_platform || '登录方式待定'}</Tag>
|
||||
{item.rank_level ? <Tag bordered={false}>{item.rank_level}</Tag> : null}
|
||||
</div>
|
||||
<div className="listing-card__meta">
|
||||
<span>哈夫币 {item.haf_coin_amount.toLocaleString()}</span>
|
||||
<span>{item.owner_nickname || '商行认证号主'}</span>
|
||||
</div>
|
||||
<div className="listing-card__price-row">
|
||||
<div>
|
||||
<span className="listing-card__price">{money(item.price_cent)}</span>
|
||||
<span className="listing-card__unit"> / 单</span>
|
||||
<Link to={`/listings/${item.id}`} className="listing-card-link">
|
||||
<Card className="listing-card" hoverable cover={<ListingCover item={item} />}>
|
||||
<div className="listing-card__head">
|
||||
<Typography.Text className="listing-card__no">{item.listing_no}</Typography.Text>
|
||||
<Tag color={item.in_transaction ? 'default' : 'green'} bordered={false}>
|
||||
{item.in_transaction ? '租赁中' : '可租'}
|
||||
</Tag>
|
||||
</div>
|
||||
<span className="listing-card__deposit">押金 {money(item.deposit_amount_cent)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Typography.Title level={4} className="listing-card__title" ellipsis={{ rows: 2 }}>
|
||||
{item.title}
|
||||
</Typography.Title>
|
||||
<div className="listing-card__tags">
|
||||
<Tag bordered={false}>{item.server_region || '区服待定'}</Tag>
|
||||
<Tag bordered={false}>{item.login_platform || '登录方式待定'}</Tag>
|
||||
{item.rank_level ? <Tag bordered={false}>{item.rank_level}</Tag> : null}
|
||||
</div>
|
||||
<div className="listing-card__meta">
|
||||
<span>哈夫币 {item.haf_coin_amount.toLocaleString()}</span>
|
||||
<span>{item.owner_nickname || '商行认证号主'}</span>
|
||||
</div>
|
||||
<div className="listing-card__price-row">
|
||||
<div>
|
||||
<span className="listing-card__price">{money(item.price_cent)}</span>
|
||||
<span className="listing-card__unit"> / 单</span>
|
||||
</div>
|
||||
<span className="listing-card__deposit">押金 {money(item.deposit_amount_cent)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user