diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md index 1199bd1..c8d507b 100644 --- a/docs/Phase1-验收报告.md +++ b/docs/Phase1-验收报告.md @@ -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. 分销后台主题轻改。 diff --git a/docs/Phase2-移动端下单入口验收.md b/docs/Phase2-移动端下单入口验收.md new file mode 100644 index 0000000..f383cb0 --- /dev/null +++ b/docs/Phase2-移动端下单入口验收.md @@ -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. 订单群聊入口与证据上传。 diff --git a/docs/项目计划.md b/docs/项目计划.md index d2283ea..efe477d 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -381,7 +381,7 @@ hfb_jysh/ ### Phase 3:C 端 PC + 移动端(3 周) -> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;移动端下单创建、PC 端完整链路待迁移。 +> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;移动端实名认证入口、PC 端完整链路待迁移。 - 首页、商品列表、商品详情、撞车商品。 - 登录、注册、找回密码、实名认证。 diff --git a/frontend/src/features/mobile/MobileListingDetailPage.tsx b/frontend/src/features/mobile/MobileListingDetailPage.tsx index e061b30..9650f9a 100644 --- a/frontend/src/features/mobile/MobileListingDetailPage.tsx +++ b/frontend/src/features/mobile/MobileListingDetailPage.tsx @@ -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:
{content || '暂无内容'}
, + 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 (
@@ -143,13 +213,104 @@ export function MobileListingDetailPage() {
+ + { + if (!submitting) setConfirmOpen(false) + }} + bodyClassName="mobile-order-sheet" + > +
确认租赁
+

+ 提交后会锁定该商品并生成待支付订单;支付成功后才进入交接流程。 +

+ +
+
+ 商品 + {data.title} +
+
+ 租金 + {money(data.price_cent)} +
+
+ 押金 + {money(data.deposit_amount_cent)} +
+
+ 哈夫币 + {data.haf_coin_amount.toLocaleString()} M +
+
+ 号主 + {data.owner_nickname || `#${data.owner_id}`} +
+
+ +
+ 租赁协议 +
+ + + +
+
+ + + 我已阅读并同意上述协议与租后须知 + + +
+ + +
+
) } diff --git a/frontend/src/features/orders/api.ts b/frontend/src/features/orders/api.ts index ca1bafd..ddc5b1c 100644 --- a/frontend/src/features/orders/api.ts +++ b/frontend/src/features/orders/api.ts @@ -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>('/orders', { listing_id: Number(listingId) }) + ) +} + +export function fetchOrderAgreements() { + return unwrapData( + apiClient.get>('/order-agreements') + ) +} + +export function fetchPostRentalNotice() { + return unwrapData( + apiClient.get>('/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( apiClient.post>(`/orders/${id}/start-payment`, { diff --git a/frontend/src/features/orders/types.ts b/frontend/src/features/orders/types.ts index d96e01e..81eacaa 100644 --- a/frontend/src/features/orders/types.ts +++ b/frontend/src/features/orders/types.ts @@ -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 +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 9ee8402..e6ba25b 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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;