feat(storefront): 新增 PC 商品详情与下单确认
This commit is contained in:
+2
-2
@@ -193,7 +193,7 @@ dist/assets/AdminLayout-*.js 36.87 kB │ gzip 13.02 kB
|
||||
|
||||
## 5. 已知限制
|
||||
|
||||
1. 移动端商品详情、下单确认、订单列表、详情、支付、交接、确认收号、结账、还价与申诉已接入真实接口;移动端实名认证入口与 PC 完整链路仍待迁移。
|
||||
1. 移动端商品详情、下单确认、订单列表、详情、支付、交接、确认收号、结账、还价与申诉已接入真实接口;PC 商品详情与下单已接通,移动端实名认证入口、PC 订单中心与 PC 订单操作仍待迁移。
|
||||
2. 后台导航已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置、分销站点,长尾能力仍待迁移。
|
||||
3. 分销后台已完成主题、Logo、登录页与 Shell 轻改,页面结构未重写。
|
||||
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`,申诉 `dispute:view` / `dispute:arbitrate`,财务 `wallet:view`(资金出款列表)与 `finance:manual_disbursement` / `finance:operating_expense`,公告 `announcement:view` / `announcement:manage`,审计日志 `audit_log:view`,系统配置 `system_config:view` / `system_config:update`,分销站点 `distribution:site_view` / `distribution:site_manage`(列表也接受 `qrcode:view`);当前菜单权限码已全部对照后端。
|
||||
@@ -205,7 +205,7 @@ dist/assets/AdminLayout-*.js 36.87 kB │ gzip 13.02 kB
|
||||
|
||||
## 6. 建议下一批次
|
||||
|
||||
1. 移动端实名认证入口、证据上传与订单群聊入口。
|
||||
1. PC 订单中心与订单操作页、移动端实名认证入口、证据上传与订单群聊入口。
|
||||
2. 后台列表页原型:DataTable、FilterBar、详情 Drawer、批量操作。
|
||||
3. 先迁移后台订单列表与商品审核列表。
|
||||
4. 分销后台主题轻改。
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Phase 2 验收报告(PC 商品详情与下单)
|
||||
|
||||
> 日期:2026-10-30
|
||||
> 项目:解忧商行(hfb_jysh)
|
||||
> 范围:`/listings/:id`
|
||||
> 结论:**本切片通过,PC 商品详情、协议查看、下单确认与订单创建已接通**
|
||||
|
||||
---
|
||||
|
||||
## 1. 页面与路由
|
||||
|
||||
- 路由:`/listings/:id`
|
||||
- 页面:`frontend/src/features/listings/ListingDetailPage.tsx`
|
||||
- 首页商品卡片改为可点击,进入对应详情
|
||||
- 详情页沿用 StorefrontLayout 顶部导航与页脚
|
||||
|
||||
## 2. 页面结构
|
||||
|
||||
- 面包屑与返回按钮
|
||||
- 左侧图片画廊:封面 + 截图缩略图,点击放大预览
|
||||
- 右侧下单卡:
|
||||
- 商品编号、状态、标题、区服、登录方式、段位
|
||||
- 租金、押金、哈夫币、号主
|
||||
- 风险提示
|
||||
- 「确认租赁」按钮
|
||||
- 协议入口
|
||||
- 账号说明、资产摘要、交易流程
|
||||
- 右侧信任说明:实名审核、全程留痕、账单透明
|
||||
- 移动端断点下取消右侧吸顶,改为单列
|
||||
|
||||
## 3. 下单确认
|
||||
|
||||
- 未登录跳转 `/login?redirect=/listings/:id`
|
||||
- 自己发布的商品禁止租用
|
||||
- 租赁中的商品禁止租用
|
||||
- 未实名用户禁止下单并提示先完成实名
|
||||
- 确认弹窗展示商品、租金、押金、哈夫币、号主
|
||||
- 必须勾选协议后才能提交
|
||||
- 协议内容来自:
|
||||
- `GET /api/order-agreements`
|
||||
- `GET /api/post-rental-notice`
|
||||
- 调用 `POST /api/orders` 创建订单
|
||||
- 创建成功提示「去支付」,跳转 `/m/orders/:id` 继续支付
|
||||
|
||||
## 4. 验证结果
|
||||
|
||||
```text
|
||||
npx tsc --noEmit 通过
|
||||
npm run build 通过(入口 499.23 kB,gzip 161.80 kB)
|
||||
GET /listings/1 200
|
||||
GET /api/listings/1 ok
|
||||
GET /api/order-agreements ok
|
||||
GET /api/post-rental-notice ok
|
||||
```
|
||||
|
||||
当前开发库商品 1 的号主是联调用户,页面会正确禁止自租;未执行真实下单写操作。
|
||||
|
||||
## 5. 已知限制
|
||||
|
||||
1. PC 端还没有独立订单中心与 PC 订单操作页,创建订单后暂跳移动订单详情继续支付。
|
||||
2. PC 下单确认页未做成长等级、免押额度与最终优惠试算,最终金额以订单详情为准。
|
||||
3. 客服入口、实名认证入口仍未迁移。
|
||||
4. 图片画廊没有视频或 3D 资产展示。
|
||||
5. 商品详情未接撞车商城独立样式。
|
||||
|
||||
## 6. 下一步建议
|
||||
|
||||
1. PC 订单中心与 PC 订单操作页。
|
||||
2. PC 实名认证与客服入口。
|
||||
3. PC 发布商品与卖家工作台。
|
||||
4. 订单群聊、证据上传与聊天 SSE。
|
||||
+1
-1
@@ -381,7 +381,7 @@ hfb_jysh/
|
||||
|
||||
### Phase 3:C 端 PC + 移动端(3 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;移动端实名认证入口、PC 端完整链路待迁移。
|
||||
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;PC 商品详情与下单已接通,移动端实名认证入口、PC 订单中心与操作页待迁移。
|
||||
|
||||
- 首页、商品列表、商品详情、撞车商品。
|
||||
- 登录、注册、找回密码、实名认证。
|
||||
|
||||
@@ -8,6 +8,11 @@ import { StorefrontLayout } from '@/layouts/StorefrontLayout'
|
||||
const ListingListPage = lazy(() =>
|
||||
import('@/features/listings/ListingListPage').then(module => ({ default: module.ListingListPage }))
|
||||
)
|
||||
const ListingDetailPage = lazy(() =>
|
||||
import('@/features/listings/ListingDetailPage').then(module => ({
|
||||
default: module.ListingDetailPage,
|
||||
}))
|
||||
)
|
||||
const LoginPage = lazy(() =>
|
||||
import('@/features/auth/LoginPage').then(module => ({ default: module.LoginPage }))
|
||||
)
|
||||
@@ -143,6 +148,7 @@ export function AppRouter() {
|
||||
<Routes>
|
||||
<Route element={<StorefrontLayout />}>
|
||||
<Route index element={<ListingListPage />} />
|
||||
<Route path="listings/:id" element={<ListingDetailPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -454,6 +454,291 @@ a {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.listing-card-link {
|
||||
display: block;
|
||||
height: 100%;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.listing-card-link:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.listing-detail-page {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 26px 24px 64px;
|
||||
}
|
||||
|
||||
.listing-detail-page__crumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.listing-detail-page__hero {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.listing-detail-gallery {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.listing-detail-gallery__main {
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #eef1ea;
|
||||
}
|
||||
|
||||
.listing-detail-gallery__main .ant-image,
|
||||
.listing-detail-gallery__main img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.listing-detail-gallery__main img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.listing-detail-gallery__thumbs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.listing-detail-gallery__thumbs .ant-image {
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.listing-detail-gallery__thumbs img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.listing-detail-gallery__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
min-height: 360px;
|
||||
border-radius: 8px;
|
||||
background: var(--paper);
|
||||
color: var(--brand-ink);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.listing-buy-card {
|
||||
position: sticky;
|
||||
top: 84px;
|
||||
border-color: var(--line);
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.listing-buy-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.listing-buy-card__title {
|
||||
margin: 12px 0 10px !important;
|
||||
color: var(--brand-ink) !important;
|
||||
font-size: 26px !important;
|
||||
line-height: 1.35 !important;
|
||||
}
|
||||
|
||||
.listing-buy-card__price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.listing-buy-card__price span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.listing-buy-card__price strong {
|
||||
color: var(--brand-seal);
|
||||
font-family: 'Inter', 'DIN Alternate', sans-serif;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.listing-buy-card__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.listing-buy-card__meta div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.listing-buy-card__meta span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.listing-buy-card__meta strong {
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.listing-buy-card__notice {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.listing-buy-card__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.listing-buy-card__links button {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--brand-seal);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.listing-buy-card__links button:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.listing-detail-page__body {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.listing-detail-panel {
|
||||
margin-bottom: 18px;
|
||||
border-color: var(--line);
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.listing-detail-description {
|
||||
margin-bottom: 0 !important;
|
||||
color: var(--ink-secondary);
|
||||
line-height: 1.85;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.listing-process {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.listing-process__step {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.listing-process__step:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.listing-process__step > span {
|
||||
color: var(--brand-seal);
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.listing-process__step strong {
|
||||
color: var(--brand-ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.listing-process__step p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.listing-trust-card {
|
||||
border-color: var(--line);
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.listing-trust-card__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.listing-trust-card__item > .anticon {
|
||||
margin-top: 2px;
|
||||
color: var(--brand-seal);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.listing-trust-card__item div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.listing-trust-card__item strong {
|
||||
color: var(--brand-ink);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.listing-trust-card__item span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.listing-agreement-content {
|
||||
max-height: 62vh;
|
||||
overflow-y: auto;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.listing-detail-page {
|
||||
padding: 20px 16px 48px;
|
||||
}
|
||||
|
||||
.listing-buy-card {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.listing-buy-card__meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-panel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user