From 52b6d439bf3a9901495b780e891c57a5db82675c Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 13:47:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20=E6=8E=A5=E5=85=A5=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E6=94=AF=E4=BB=98=E4=BA=A4=E6=8E=A5=E7=BB=93=E8=B4=A6?= =?UTF-8?q?=E4=B8=8E=E7=94=B3=E8=AF=89=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Phase1-验收报告.md | 4 +- docs/Phase2-移动端订单操作验收.md | 103 +++ docs/项目计划.md | 2 +- .../mobile/MobileOrderActionPanel.tsx | 697 ++++++++++++++++++ .../features/mobile/MobileOrderDetailPage.tsx | 86 ++- frontend/src/features/orders/api.ts | 73 +- frontend/src/features/orders/types.ts | 55 ++ frontend/src/styles/global.css | 141 ++++ 8 files changed, 1122 insertions(+), 39 deletions(-) create mode 100644 docs/Phase2-移动端订单操作验收.md create mode 100644 frontend/src/features/mobile/MobileOrderActionPanel.tsx diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md index ec39baa..1199bd1 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. 移动端订单列表/详情与消息中心已接入真实接口;下单、支付、交接、归还、结账等操作仍待迁移。 +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..533b592 --- /dev/null +++ b/docs/Phase2-移动端订单操作验收.md @@ -0,0 +1,103 @@ +# Phase 2 验收报告(移动端订单操作) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 范围:`/m/orders/:id` +> 结论:**本切片通过,移动端订单支付、交接、收号、结账、还价与申诉操作已接真实接口** + +--- + +## 1. 接口与权限 + +所有接口都要求用户登录,并校验订单交易双方身份。 + +| 能力 | 接口 | 可操作角色与状态 | +| --- | --- | --- | +| 发起支付 | `POST /api/orders/:id/start-payment` | 租客,待支付 | +| 查询支付 | `GET /api/orders/:id/query-payment` | 租客,待支付 | +| 取消订单 | `POST /api/orders/:id/cancel` | 租客,待支付/待交接;待交接取消需客服审核退款 | +| 提交交接 | `POST /api/orders/:id/handoff` | 号主,待交接且轮到号主/号主超时 | +| 确认收号 | `POST /api/orders/:id/confirm-receive` | 租客,待租客确认收号 | +| 交接记录 | `GET /api/orders/:id/handoff-records` | 交易双方 | +| 发起结账 | `POST /api/orders/:id/checkout` | 租客,使用中/逾期且已收号 | +| 确认结账 | `POST /api/orders/:id/checkout/confirm` | 号主,轮到号主且押金无缺口 | +| 同意结账 | `POST /api/orders/:id/checkout/accept` | 租客,轮到租客且押金无缺口 | +| 修改方案 | `POST /api/orders/:id/checkout/counter` | 当前轮到的一方,未超过 6 轮 | +| 发起申诉 | `POST /api/orders/:id/dispute` | 交易双方,订单未终态且无进行中申诉 | +| 取消申诉 | `POST /api/orders/:id/dispute/cancel` | 申诉发起人,申诉处理中 | + +## 2. 页面结构 + +`frontend/src/features/mobile/MobileOrderDetailPage.tsx` + +- 保留订单概览、商品信息、金额、时间、结账单与申诉信息 +- 新增「交接与结账记录」列表,展示交接说明、结账记录、确认状态与时间 +- 底部新增订单操作区,由新组件 `MobileOrderActionPanel.tsx` 承载 +- 操作按钮按角色、订单状态、交接状态与结账轮次动态显示 + +## 3. 支付 + +- 租客待支付时显示「去支付」「检查支付状态」 +- 支付方式底部弹层:微信支付 / 支付宝 +- 调用 `start-payment` 后优先打开 `jspay_url`、`td_code` 或 `jspay_info` 中的 HTTP 地址 +- 支付单已支付或页面从支付渠道返回后,可点击「检查支付状态」查询并刷新订单 +- 无支付链接时提示支付单已创建,避免误判支付成功 + +## 4. 交接与收号 + +- 号主提交交接说明:文本框必填,最多 1000 字,提交后进入待租客确认 +- 租客确认收号:二次确认后订单进入使用中并开始计时 +- 交接记录以列表展示,包含内容、创建时间、租客/号主确认时间 + +## 5. 结账 + +租客发起结账: + +- 结账备注、消耗品金额、哈夫币消耗、押金扣除、证据链接 +- 金额前端以元输入,提交前转换为分 +- 提交前二次确认,明确提示结账会影响租金、押金与双方结算 + +号主/租客确认: + +- 只有当前轮到的一方显示「确认结账」或「同意结账方案」 +- 押金缺口大于 0 时禁止直接完结,提示修改方案或发起争议 + +修改方案: + +- 回填当前结账单金额 +- 消耗品金额、哈夫币消耗、押金扣除、修改原因、证据链接 +- 展示当前协商轮次,最多 6 轮 + +## 6. 申诉 + +- 普通阶段可选:无法登录、账号异常、租金/押金争议、退款问题、其他 +- 结账阶段自动提交 `checkout_dispute`,避免用户选错类型 +- 申诉说明必填,证据链接按行拆分 +- 提交前二次确认,明确进入仲裁流程 +- 申诉发起人可取消申诉,取消后订单恢复到申诉前状态 + +## 7. 验证结果 + +```text +npx tsc --noEmit 通过 +npm run build 通过(入口 498.51 kB,gzip 161.57 kB) +GET /m/orders/1 200 +``` + +开发库当前没有订单数据,未执行支付、交接、结账或申诉写操作;接口路径、请求体与状态校验均按后端 handler/service 实现对齐。 + +## 8. 已知限制 + +1. 证据只支持填写链接,尚未接后台文件上传与图片预览。 +2. 支付依赖渠道返回的 H5 地址或 App 内跳转,未内置二维码渲染。 +3. 未展示订单群聊入口,聊天仍从消息中心进入。 +4. 移动端下单创建流程尚未迁移,当前从商品详情直接下单仍待补齐。 +5. 结账未接资源使用量快照,消耗品金额由租客手工填写。 + +## 9. 下一步建议 + +1. 移动端下单创建与支付前确认页。 +2. 证据图片上传与订单群聊入口。 +3. 结账资源使用量快照与金额试算。 +4. PC 商品详情与下单链路。 +5. 聊天 SSE 与附件上传。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 3041195..d2283ea 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/MobileOrderActionPanel.tsx b/frontend/src/features/mobile/MobileOrderActionPanel.tsx new file mode 100644 index 0000000..8e7c01a --- /dev/null +++ b/frontend/src/features/mobile/MobileOrderActionPanel.tsx @@ -0,0 +1,697 @@ +import { useState } from 'react' +import { + ActionSheet, + Button, + Dialog, + Input, + Popup, + Selector, + TextArea, + Toast, +} from 'antd-mobile' + +import { + acceptCheckout, + cancelMyOrder, + cancelOrderDispute, + confirmCheckout, + confirmReceive, + counterCheckout, + createOrderDispute, + queryOrderPayment, + startOrderPayment, + submitCheckout, + submitHandoff, +} from '@/features/orders/api' +import type { UserOrderDetail } from '@/features/orders/types' +import { useSessionStore } from '@/stores/session' +import { formatMoney } from '@/shared/utils/format' + +type BusyAction = + | 'cancel' + | 'pay' + | 'check-pay' + | 'handoff' + | 'receive' + | 'checkout' + | 'confirm-checkout' + | 'accept-checkout' + | 'counter' + | 'dispute' + | 'cancel-dispute' + | null + +interface MobileOrderActionPanelProps { + order: UserOrderDetail + onChanged: () => void | Promise +} + +const disputeTypes = [ + { label: '无法登录', value: 'cannot_login' }, + { label: '账号异常', value: 'account_abnormal' }, + { label: '租金/押金争议', value: 'rent_dispute' }, + { label: '退款问题', value: 'refund_issue' }, + { label: '其他', value: 'other' }, +] + +function splitLines(value: string) { + return value + .split(/\r?\n/) + .map(item => item.trim()) + .filter(Boolean) +} + +function toCent(value: string) { + const amount = Number(value) + if (!Number.isFinite(amount) || amount < 0) return 0 + return Math.round(amount * 100) +} + +function toNumber(value: string) { + const amount = Number(value) + return Number.isFinite(amount) && amount >= 0 ? amount : 0 +} + +export function MobileOrderActionPanel({ order, onChanged }: MobileOrderActionPanelProps) { + const user = useSessionStore(state => state.user) + const [busy, setBusy] = useState(null) + const [payOpen, setPayOpen] = useState(false) + const [handoffOpen, setHandoffOpen] = useState(false) + const [handoffContent, setHandoffContent] = useState('') + const [checkoutOpen, setCheckoutOpen] = useState(false) + const [checkoutContent, setCheckoutContent] = useState('') + const [consumableAmount, setConsumableAmount] = useState('0') + const [coinConsumed, setCoinConsumed] = useState('0') + const [otherAmount, setOtherAmount] = useState('0') + const [checkoutEvidence, setCheckoutEvidence] = useState('') + const [counterOpen, setCounterOpen] = useState(false) + const [counterConsumable, setCounterConsumable] = useState('0') + const [counterCoin, setCounterCoin] = useState('0') + const [counterDeduct, setCounterDeduct] = useState('0') + const [counterReason, setCounterReason] = useState('') + const [counterEvidence, setCounterEvidence] = useState('') + const [disputeOpen, setDisputeOpen] = useState(false) + const [disputeType, setDisputeType] = useState('cannot_login') + const [disputeDescription, setDisputeDescription] = useState('') + const [disputeEvidence, setDisputeEvidence] = useState('') + + const isOwner = order.owner_id === user?.id || order.price_role === 'owner' + const isRenter = order.renter_id === user?.id || order.price_role === 'renter' + const checkout = order.checkout + const shortfall = checkout?.shortfall_cent ?? 0 + + const canPay = isRenter && order.status === 'pending_payment' + const canSubmitHandoff = + isOwner && + order.status === 'pending_handoff' && + ['pending_owner', 'owner_timeout'].includes(order.handoff_status) + const canConfirmReceive = + isRenter && order.status === 'pending_handoff' && order.handoff_status === 'pending_renter_confirm' + const canSubmitCheckout = + isRenter && + ['renting', 'overdue'].includes(order.status) && + ['received', 'return_overdue'].includes(order.handoff_status) && + !checkout + const ownerCheckoutPending = + isOwner && + (order.status === 'pending_checkout_confirm' || + (order.status === 'abnormal' && + [ + 'owner_checkout_confirm_timeout', + 'owner_return_confirm_timeout', + 'pending_owner_checkout', + ].includes(order.handoff_status))) + const renterCheckoutPending = isRenter && order.status === 'pending_checkout_accept' + const canConfirmCheckout = + ownerCheckoutPending && Boolean(checkout) && checkout?.can_accept !== false && shortfall <= 0 + const canAcceptCheckout = + renterCheckoutPending && Boolean(checkout) && checkout?.can_accept !== false && shortfall <= 0 + const canCounterCheckout = Boolean(checkout?.can_counter) + const canCancelOrder = isRenter && ['pending_payment', 'pending_handoff'].includes(order.status) + const canOpenDispute = + (isOwner || isRenter) && + !order.active_dispute && + !['completed', 'cancelled', 'closed', 'disputing', 'checkout_disputing'].includes(order.status) + const canCancelDispute = + Boolean(order.active_dispute) && + order.active_dispute?.initiator_type !== 'admin' && + order.active_dispute?.initiator_id === user?.id && + ['disputing', 'checkout_disputing'].includes(order.status) + const checkoutDisputeStage = ['pending_checkout_confirm', 'pending_checkout_accept'].includes( + order.status + ) + + const hasAction = + canPay || + canSubmitHandoff || + canConfirmReceive || + canSubmitCheckout || + canConfirmCheckout || + canAcceptCheckout || + canCounterCheckout || + canCancelOrder || + canOpenDispute || + canCancelDispute + + const run = async (action: BusyAction, fn: () => Promise) => { + setBusy(action) + try { + await fn() + await onChanged() + } catch (error) { + Toast.show({ + icon: 'fail', + content: error instanceof Error ? error.message : '操作失败,请稍后再试', + }) + } finally { + setBusy(null) + } + } + + const handleCancel = async () => { + const refundReview = order.status === 'pending_handoff' + const confirmed = await Dialog.confirm({ + content: refundReview + ? '订单已支付,取消后需要客服审核退款,审核通过前账号仍会保持占用。确定申请取消吗?' + : '取消后订单会关闭,已冻结的押金将按原路退回。确定取消吗?', + confirmText: refundReview ? '申请取消' : '取消订单', + cancelText: '再想想', + }) + if (!confirmed) return + await run('cancel', async () => { + await cancelMyOrder(order.id) + Toast.show({ + icon: 'success', + content: refundReview ? '取消申请已提交,等待客服审核退款' : '订单已取消', + }) + }) + } + + const handlePay = async (payWay: 'WXZF' | 'ZFBZF') => { + setPayOpen(false) + await run('pay', async () => { + const payment = await startOrderPayment(order.id, payWay) + if (payment.paid) { + Toast.show({ icon: 'success', content: '支付成功' }) + return + } + const payURL = payment.jspay_url || payment.td_code || payment.jspay_info || '' + if (/^https?:\/\//i.test(payURL)) { + Toast.show({ content: '正在打开支付页面' }) + window.location.assign(payURL) + return + } + Toast.show({ content: '支付单已创建,请稍后检查支付状态' }) + }) + } + + const handleCheckPayment = async () => { + await run('check-pay', async () => { + const payment = await queryOrderPayment(order.id) + if (payment.paid) { + Toast.show({ icon: 'success', content: '支付成功,订单已进入待交接' }) + return + } + Toast.show({ content: '支付暂未完成,请稍后再试' }) + }) + } + + const submitHandoffAction = async () => { + if (!handoffContent.trim()) { + Toast.show({ content: '请填写交接说明' }) + return + } + await run('handoff', async () => { + await submitHandoff(order.id, handoffContent.trim()) + setHandoffOpen(false) + setHandoffContent('') + Toast.show({ icon: 'success', content: '交接说明已提交' }) + }) + } + + const confirmReceiveAction = async () => { + const confirmed = await Dialog.confirm({ + content: '请确认账号可以正常登录、资料与交接说明一致。确认后订单进入使用中。', + confirmText: '确认收号', + cancelText: '再检查一下', + }) + if (!confirmed) return + await run('receive', async () => { + await confirmReceive(order.id) + Toast.show({ icon: 'success', content: '已确认收号' }) + }) + } + + const openCheckout = () => { + setCheckoutContent('') + setConsumableAmount('0') + setCoinConsumed(String(checkout?.coin_consumed_m ?? 0)) + setOtherAmount('0') + setCheckoutEvidence('') + setCheckoutOpen(true) + } + + const submitCheckoutAction = async () => { + if (!checkoutContent.trim()) { + Toast.show({ content: '请填写结账备注' }) + return + } + const confirmed = await Dialog.confirm({ + content: + '结账会直接影响租金、押金赔付与双方结算金额,提交后将发送给对方确认。请确认金额与说明已经核对。', + confirmText: '提交结账', + cancelText: '再检查一下', + }) + if (!confirmed) return + await run('checkout', async () => { + await submitCheckout(order.id, { + content: checkoutContent.trim(), + consumable_amount_cent: toCent(consumableAmount), + coin_consumed_m: toNumber(coinConsumed), + other_amount_cent: toCent(otherAmount), + evidence_urls: splitLines(checkoutEvidence), + }) + setCheckoutOpen(false) + Toast.show({ icon: 'success', content: '结账已发起,等待对方确认' }) + }) + } + + const confirmCheckoutAction = async () => { + if (shortfall > 0) { + Toast.show({ content: '押金不足,请修改方案或发起争议' }) + return + } + const confirmed = await Dialog.confirm({ + content: '确认账号状态和扣款金额无误吗?确认后订单将完成结算。', + confirmText: '确认结账', + cancelText: '再检查一下', + }) + if (!confirmed) return + await run('confirm-checkout', async () => { + await confirmCheckout(order.id) + Toast.show({ icon: 'success', content: '结账已确认,订单完成' }) + }) + } + + const acceptCheckoutAction = async () => { + if (shortfall > 0) { + Toast.show({ content: '押金不足,请修改方案或发起争议' }) + return + } + const confirmed = await Dialog.confirm({ + content: '确定同意当前结账方案吗?同意后订单将完成结算。', + confirmText: '同意结账', + cancelText: '再检查一下', + }) + if (!confirmed) return + await run('accept-checkout', async () => { + await acceptCheckout(order.id) + Toast.show({ icon: 'success', content: '已同意结账,订单完成' }) + }) + } + + const openCounter = () => { + setCounterConsumable(String(((checkout?.consumable_amount_cent ?? 0) / 100).toFixed(2))) + setCounterCoin(String(checkout?.coin_consumed_m ?? 0)) + setCounterDeduct(String(((checkout?.deposit_deduct_amount_cent ?? 0) / 100).toFixed(2))) + setCounterReason('') + setCounterEvidence('') + setCounterOpen(true) + } + + const counterCheckoutAction = async () => { + if (!counterReason.trim()) { + Toast.show({ content: '请填写修改原因' }) + return + } + await run('counter', async () => { + await counterCheckout(order.id, { + content: counterReason.trim(), + reason: counterReason.trim(), + consumable_amount_cent: toCent(counterConsumable), + coin_consumed_m: toNumber(counterCoin), + other_amount_cent: toCent(counterDeduct), + deposit_deduct_amount_cent: toCent(counterDeduct), + evidence_urls: splitLines(counterEvidence), + }) + setCounterOpen(false) + Toast.show({ icon: 'success', content: '结账方案已提交' }) + }) + } + + const openDispute = () => { + setDisputeType(checkoutDisputeStage ? 'checkout_dispute' : 'cannot_login') + setDisputeDescription('') + setDisputeEvidence('') + setDisputeOpen(true) + } + + const createDisputeAction = async () => { + if (!disputeDescription.trim()) { + Toast.show({ content: '请填写申诉说明' }) + return + } + const confirmed = await Dialog.confirm({ + content: '提交后订单会进入仲裁流程,客服会根据双方证据处理。确定提交吗?', + confirmText: '提交申诉', + cancelText: '再想想', + }) + if (!confirmed) return + await run('dispute', async () => { + await createOrderDispute(order.id, { + type: checkoutDisputeStage ? 'checkout_dispute' : disputeType, + description: disputeDescription.trim(), + evidence_urls: splitLines(disputeEvidence), + }) + setDisputeOpen(false) + Toast.show({ icon: 'success', content: '申诉已提交' }) + }) + } + + const cancelDisputeAction = async () => { + const confirmed = await Dialog.confirm({ + content: '取消后订单会恢复到发起申诉前的流程,确定继续吗?', + confirmText: '取消申诉', + cancelText: '保留申诉', + }) + if (!confirmed) return + await run('cancel-dispute', async () => { + await cancelOrderDispute(order.id) + Toast.show({ icon: 'success', content: '申诉已取消' }) + }) + } + + if (!hasAction) return null + + return ( +
+
+ 订单操作 + {checkout && shortfall > 0 ? {formatMoney(shortfall)} 押金缺口 : null} +
+ + {canPay ? ( +
+ + +
+ ) : null} + + {canSubmitHandoff ? ( + + ) : null} + + {canConfirmReceive ? ( + + ) : null} + + {canSubmitCheckout ? ( + + ) : null} + + {canConfirmCheckout ? ( + + ) : null} + + {canAcceptCheckout ? ( + + ) : null} + + {canCounterCheckout ? ( + + ) : null} + + {canOpenDispute ? ( + + ) : null} + + {canCancelDispute ? ( + + ) : null} + + {canCancelOrder ? ( + + ) : null} + + setPayOpen(false)} + onAction={action => { + if (action.key === 'WXZF' || action.key === 'ZFBZF') { + void handlePay(action.key) + } + }} + /> + + setHandoffOpen(false)} + bodyClassName="mobile-order-sheet" + > +
提交交接说明
+

+ 写清账号、密码、区服、注意事项与可登录时间,租客确认收号后订单开始计时。 +

+