feat(mobile): 接入商品详情下单确认与协议
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 完整链路仍待迁移。
|
||||
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. 移动端实名认证入口、证据上传与订单群聊入口。
|
||||
2. 后台列表页原型:DataTable、FilterBar、详情 Drawer、批量操作。
|
||||
3. 先迁移后台订单列表与商品审核列表。
|
||||
4. 分销后台主题轻改。
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Phase 2 验收报告(移动端下单入口)
|
||||
|
||||
> 日期:2026-10-30
|
||||
> 项目:解忧商行(hfb_jysh)
|
||||
> 范围:`/m/listings/:id`
|
||||
> 结论:**本切片通过,移动端商品详情已接入下单确认、协议查看与订单创建**
|
||||
|
||||
---
|
||||
|
||||
## 1. 接口
|
||||
|
||||
| 能力 | 接口 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 创建订单 | `POST /api/orders` | 登录且已实名;请求体 `{ listing_id }` |
|
||||
| 虚拟资产购买协议 / 租客协议 | `GET /api/order-agreements` | 公开配置 |
|
||||
| 租后须知 | `GET /api/post-rental-notice` | 公开配置 |
|
||||
| 商品详情 | `GET /api/listings/:id` | 公开列表数据 |
|
||||
|
||||
后端创建订单时会锁定商品并校验:
|
||||
|
||||
- 商品已发布、审核通过且未在交易中
|
||||
- 不能租用自己发布的商品
|
||||
- 同一账号/商品不能存在进行中的订单
|
||||
- 租客必须已实名
|
||||
- 分销站点、成长等级、免押额度与定价在事务内计算
|
||||
|
||||
## 2. 页面
|
||||
|
||||
`frontend/src/features/mobile/MobileListingDetailPage.tsx`
|
||||
|
||||
- 保留商品图、标题、价格、押金、哈夫币、账号说明、资产摘要
|
||||
- 底部「确认租赁」打开下单确认弹层
|
||||
- 自己发布的商品显示「不能租自己的商品」并禁用
|
||||
- 租赁中的商品显示「商品租赁中」并禁用
|
||||
- 未登录时跳转 `/m/login?redirect=/m/listings/:id`
|
||||
- 未实名时弹窗提示先完成实名
|
||||
|
||||
## 3. 下单确认弹层
|
||||
|
||||
- 商品、租金、押金、哈夫币、号主信息汇总
|
||||
- 协议入口:
|
||||
- 《虚拟资产购买协议》
|
||||
- 《租客协议》
|
||||
- 《租后须知》
|
||||
- 协议在弹窗中滚动阅读,正文来自系统配置
|
||||
- 必须勾选「我已阅读并同意上述协议与租后须知」
|
||||
- 提交后调用 `POST /orders`,成功跳转 `/m/orders/:id`
|
||||
- 创建期间按钮 loading,禁止重复提交
|
||||
|
||||
## 4. 验证结果
|
||||
|
||||
```text
|
||||
npx tsc --noEmit 通过
|
||||
npm run build 通过(入口 498.55 kB,gzip 161.62 kB)
|
||||
GET /m/listings/1 200
|
||||
GET /api/order-agreements ok,返回购买协议与租客协议
|
||||
GET /api/post-rental-notice ok,返回租后须知
|
||||
GET /api/listings/1 ok,返回联调商品
|
||||
```
|
||||
|
||||
开发库商品 1 的号主就是当前联调用户,页面会正确禁止租用自己商品;未执行真实下单写操作。
|
||||
|
||||
## 5. 已知限制
|
||||
|
||||
1. 移动端还没有实名认证流程,未实名用户只能看到提示。
|
||||
2. 下单确认页未展示成长等级免押额度与最终优惠试算,最终金额以创建后的订单详情为准。
|
||||
3. 未接订单创建前的库存/支付配置预检查。
|
||||
4. 客服入口仍是占位提示。
|
||||
5. PC 商品详情与下单链路尚未迁移。
|
||||
|
||||
## 6. 下一步建议
|
||||
|
||||
1. 移动端实名认证入口。
|
||||
2. PC 商品详情与下单链路。
|
||||
3. 下单后自动拉起支付方式选择。
|
||||
4. 订单群聊入口与证据上传。
|
||||
+1
-1
@@ -381,7 +381,7 @@ hfb_jysh/
|
||||
|
||||
### Phase 3:C 端 PC + 移动端(3 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;移动端下单创建、PC 端完整链路待迁移。
|
||||
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;移动端实名认证入口、PC 端完整链路待迁移。
|
||||
|
||||
- 首页、商品列表、商品详情、撞车商品。
|
||||
- 登录、注册、找回密码、实名认证。
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { Button, ErrorBlock, Skeleton, Tag, Toast } from 'antd-mobile'
|
||||
import { useState } from 'react'
|
||||
import { Button, Checkbox, Dialog, ErrorBlock, Popup, 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'
|
||||
import {
|
||||
createOrder,
|
||||
useOrderAgreements,
|
||||
usePostRentalNotice,
|
||||
} from '@/features/orders/api'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
function money(cents: number) {
|
||||
return `¥${(cents / 100).toFixed(2)}`
|
||||
@@ -27,6 +34,68 @@ export function MobileListingDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
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 showAgreement = (title: string, content?: string) => {
|
||||
Dialog.alert({
|
||||
title,
|
||||
content: <div className="mobile-agreement-content">{content || '暂无内容'}</div>,
|
||||
confirmText: '知道了',
|
||||
})
|
||||
}
|
||||
|
||||
const openConfirm = (inTransaction: boolean, isOwner: boolean) => {
|
||||
if (!token) {
|
||||
navigate(`/m/login?redirect=/m/listings/${id}`)
|
||||
return
|
||||
}
|
||||
if (isOwner) {
|
||||
Toast.show({ content: '不能租用自己的商品' })
|
||||
return
|
||||
}
|
||||
if (inTransaction) {
|
||||
Toast.show({ content: '商品正在租赁中,请稍后再看' })
|
||||
return
|
||||
}
|
||||
if (user?.realname_status !== 'verified') {
|
||||
Dialog.alert({
|
||||
title: '需要先完成实名认证',
|
||||
content: '当前账号未完成实名认证,无法创建租赁订单。请先联系客服或在实名入口完成认证后再下单。',
|
||||
confirmText: '知道了',
|
||||
})
|
||||
return
|
||||
}
|
||||
setAgreed(false)
|
||||
setConfirmOpen(true)
|
||||
}
|
||||
|
||||
const submitOrder = async () => {
|
||||
if (!data) return
|
||||
if (!agreed) {
|
||||
Toast.show({ content: '请先阅读并同意租赁协议' })
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const order = await createOrder(data.id)
|
||||
setConfirmOpen(false)
|
||||
Toast.show({ icon: 'success', content: '订单已创建,请继续支付' })
|
||||
navigate(`/m/orders/${order.id}`, { replace: true })
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
icon: 'fail',
|
||||
content: error instanceof Error ? error.message : '下单失败,请稍后再试',
|
||||
})
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
@@ -55,6 +124,7 @@ export function MobileListingDetailPage() {
|
||||
const assetEntries = Object.entries(data.asset_summary || {}).filter(
|
||||
([key]) => key !== 'online_time'
|
||||
)
|
||||
const isOwner = user?.id === data.owner_id
|
||||
|
||||
return (
|
||||
<div className="mobile-detail">
|
||||
@@ -143,13 +213,104 @@ export function MobileListingDetailPage() {
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
Toast.show({ content: '下单流程将在下一批接入' })
|
||||
}}
|
||||
disabled={isOwner || data.in_transaction}
|
||||
onClick={() => openConfirm(data.in_transaction, isOwner)}
|
||||
>
|
||||
确认租赁
|
||||
{isOwner ? '不能租自己的商品' : data.in_transaction ? '商品租赁中' : '确认租赁'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Popup
|
||||
visible={confirmOpen}
|
||||
position="bottom"
|
||||
onMaskClick={() => {
|
||||
if (!submitting) setConfirmOpen(false)
|
||||
}}
|
||||
bodyClassName="mobile-order-sheet"
|
||||
>
|
||||
<div className="mobile-order-sheet__title">确认租赁</div>
|
||||
<p className="mobile-order-sheet__hint">
|
||||
提交后会锁定该商品并生成待支付订单;支付成功后才进入交接流程。
|
||||
</p>
|
||||
|
||||
<div className="mobile-order-confirm__summary">
|
||||
<div>
|
||||
<span>商品</span>
|
||||
<strong>{data.title}</strong>
|
||||
</div>
|
||||
<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()} M</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>号主</span>
|
||||
<strong>{data.owner_nickname || `#${data.owner_id}`}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mobile-order-sheet__field">
|
||||
<span>租赁协议</span>
|
||||
<div className="mobile-detail__agreements">
|
||||
<Button
|
||||
size="mini"
|
||||
fill="none"
|
||||
onClick={() =>
|
||||
showAgreement(
|
||||
agreements.data?.virtual_asset_purchase.title || '虚拟资产购买协议',
|
||||
agreements.data?.virtual_asset_purchase.content
|
||||
)
|
||||
}
|
||||
>
|
||||
《虚拟资产购买协议》
|
||||
</Button>
|
||||
<Button
|
||||
size="mini"
|
||||
fill="none"
|
||||
onClick={() =>
|
||||
showAgreement(
|
||||
agreements.data?.renter_agreement.title || '租客协议',
|
||||
agreements.data?.renter_agreement.content
|
||||
)
|
||||
}
|
||||
>
|
||||
《租客协议》
|
||||
</Button>
|
||||
<Button
|
||||
size="mini"
|
||||
fill="none"
|
||||
onClick={() =>
|
||||
showAgreement(
|
||||
postRentalNotice.data?.title || '租后须知',
|
||||
postRentalNotice.data?.content
|
||||
)
|
||||
}
|
||||
>
|
||||
《租后须知》
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Checkbox checked={agreed} onChange={setAgreed}>
|
||||
我已阅读并同意上述协议与租后须知
|
||||
</Checkbox>
|
||||
|
||||
<div className="mobile-order-sheet__actions">
|
||||
<Button block disabled={submitting} onClick={() => setConfirmOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button block color="primary" loading={submitting} onClick={() => void submitOrder()}>
|
||||
提交订单
|
||||
</Button>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { ApiResponse } from '@/shared/api/types'
|
||||
import type {
|
||||
CounterCheckoutPayload,
|
||||
CreateDisputePayload,
|
||||
OrderAgreements,
|
||||
PostRentalNotice,
|
||||
SubmitCheckoutPayload,
|
||||
UserHandoffRecord,
|
||||
UserOrderDetail,
|
||||
@@ -54,6 +56,40 @@ export async function cancelMyOrder(id: number | string) {
|
||||
await apiClient.post(`/orders/${id}/cancel`)
|
||||
}
|
||||
|
||||
export async function createOrder(listingId: number | string) {
|
||||
return unwrapData<{ id: number }>(
|
||||
apiClient.post<ApiResponse<{ id: number }>>('/orders', { listing_id: Number(listingId) })
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchOrderAgreements() {
|
||||
return unwrapData<OrderAgreements>(
|
||||
apiClient.get<ApiResponse<OrderAgreements>>('/order-agreements')
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchPostRentalNotice() {
|
||||
return unwrapData<PostRentalNotice>(
|
||||
apiClient.get<ApiResponse<PostRentalNotice>>('/post-rental-notice')
|
||||
)
|
||||
}
|
||||
|
||||
export function useOrderAgreements() {
|
||||
return useQuery({
|
||||
queryKey: ['order-agreements'],
|
||||
queryFn: fetchOrderAgreements,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export function usePostRentalNotice() {
|
||||
return useQuery({
|
||||
queryKey: ['post-rental-notice'],
|
||||
queryFn: fetchPostRentalNotice,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export async function startOrderPayment(id: number | string, payWay: 'WXZF' | 'ZFBZF') {
|
||||
return unwrapData<UserPayment>(
|
||||
apiClient.post<ApiResponse<UserPayment>>(`/orders/${id}/start-payment`, {
|
||||
|
||||
@@ -142,3 +142,18 @@ export interface CreateDisputePayload {
|
||||
description: string
|
||||
evidence_urls: string[]
|
||||
}
|
||||
|
||||
export interface AgreementContent {
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface OrderAgreements {
|
||||
virtual_asset_purchase: AgreementContent
|
||||
renter_agreement: AgreementContent
|
||||
}
|
||||
|
||||
export interface PostRentalNotice {
|
||||
title: string
|
||||
content: string
|
||||
}
|
||||
|
||||
@@ -2166,6 +2166,64 @@ a {
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.mobile-detail__agreements {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
|
||||
.mobile-detail__agreements .adm-button {
|
||||
padding: 0;
|
||||
color: var(--brand-seal);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mobile-agreement-content {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mobile-order-confirm__summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.mobile-order-confirm__summary > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 11px 13px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.mobile-order-confirm__summary > div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.mobile-order-confirm__summary span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mobile-order-confirm__summary strong {
|
||||
color: var(--brand-ink);
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mobile-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user