feat(storefront): 新增 PC 商品详情与下单确认

This commit is contained in:
yml2213
2026-10-30 13:52:25 +08:00
parent 1f358a37c9
commit 55e914edd1
7 changed files with 820 additions and 29 deletions
+2 -2
View File
@@ -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
View File
@@ -381,7 +381,7 @@ hfb_jysh/
### Phase 3:C 端 PC + 移动端(3 周)
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;移动端实名认证入口、PC 端完整链路待迁移。
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;PC 商品详情与下单已接通,移动端实名认证入口、PC 订单中心与操作页待迁移。
- 首页、商品列表、商品详情、撞车商品。
- 登录、注册、找回密码、实名认证。
+6
View File
@@ -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>
)
}
+285
View File
@@ -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;