feat(mobile): 接入订单支付交接结账与申诉操作

This commit is contained in:
yml2213
2026-10-30 13:47:58 +08:00
parent dd1d00227c
commit 52b6d439bf
8 changed files with 1122 additions and 39 deletions
+2 -2
View File
@@ -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. 分销后台主题轻改。
+103
View File
@@ -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 与附件上传。
+1 -1
View File
@@ -381,7 +381,7 @@ hfb_jysh/
### Phase 3:C 端 PC + 移动端(3 周)
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、登录、个人中心、订单列表/详情、消息中心与聊天;PC 端与下单/支付/交接/结账操作待迁移。
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价与申诉;移动端下单创建、PC 端完整链路待迁移。
- 首页、商品列表、商品详情、撞车商品。
- 登录、注册、找回密码、实名认证。
@@ -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<void>
}
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<BusyAction>(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<void>) => {
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 (
<section className="mobile-order-actions">
<div className="mobile-order-actions__head">
<span>订单操作</span>
{checkout && shortfall > 0 ? <em>{formatMoney(shortfall)} 押金缺口</em> : null}
</div>
{canPay ? (
<div className="mobile-order-actions__row">
<Button
block
color="primary"
loading={busy === 'pay'}
onClick={() => setPayOpen(true)}
>
去支付
</Button>
<Button
block
fill="outline"
loading={busy === 'check-pay'}
onClick={() => void handleCheckPayment()}
>
检查支付状态
</Button>
</div>
) : null}
{canSubmitHandoff ? (
<Button block color="primary" onClick={() => setHandoffOpen(true)}>
提交交接说明
</Button>
) : null}
{canConfirmReceive ? (
<Button
block
color="primary"
loading={busy === 'receive'}
onClick={() => void confirmReceiveAction()}
>
确认收号
</Button>
) : null}
{canSubmitCheckout ? (
<Button block color="primary" onClick={openCheckout}>
发起结账
</Button>
) : null}
{canConfirmCheckout ? (
<Button
block
color="primary"
loading={busy === 'confirm-checkout'}
onClick={() => void confirmCheckoutAction()}
>
确认结账
</Button>
) : null}
{canAcceptCheckout ? (
<Button
block
color="primary"
loading={busy === 'accept-checkout'}
onClick={() => void acceptCheckoutAction()}
>
同意结账方案
</Button>
) : null}
{canCounterCheckout ? (
<Button block fill="outline" onClick={openCounter}>
修改结账方案
</Button>
) : null}
{canOpenDispute ? (
<Button block fill="outline" color="danger" onClick={openDispute}>
{checkoutDisputeStage ? '拒绝方案并发起争议' : '发起申诉'}
</Button>
) : null}
{canCancelDispute ? (
<Button
block
fill="outline"
color="danger"
loading={busy === 'cancel-dispute'}
onClick={() => void cancelDisputeAction()}
>
取消申诉
</Button>
) : null}
{canCancelOrder ? (
<Button
block
fill="outline"
color="danger"
loading={busy === 'cancel'}
onClick={() => void handleCancel()}
>
取消订单
</Button>
) : null}
<ActionSheet
visible={payOpen}
actions={[
{ key: 'WXZF', text: '微信支付', description: '在微信内直接拉起支付' },
{ key: 'ZFBZF', text: '支付宝支付', description: '在支付宝内直接拉起支付' },
]}
cancelText="取消"
onClose={() => setPayOpen(false)}
onAction={action => {
if (action.key === 'WXZF' || action.key === 'ZFBZF') {
void handlePay(action.key)
}
}}
/>
<Popup
visible={handoffOpen}
position="bottom"
onMaskClick={() => setHandoffOpen(false)}
bodyClassName="mobile-order-sheet"
>
<div className="mobile-order-sheet__title">提交交接说明</div>
<p className="mobile-order-sheet__hint">
写清账号、密码、区服、注意事项与可登录时间,租客确认收号后订单开始计时。
</p>
<TextArea
value={handoffContent}
onChange={setHandoffContent}
placeholder="例如:账号 xxx,密码 xxx,已解绑设备,登录后先修改密码…"
rows={6}
maxLength={1000}
showCount
/>
<div className="mobile-order-sheet__actions">
<Button block onClick={() => setHandoffOpen(false)}>
取消
</Button>
<Button block color="primary" loading={busy === 'handoff'} onClick={() => void submitHandoffAction()}>
提交交接
</Button>
</div>
</Popup>
<Popup
visible={checkoutOpen}
position="bottom"
onMaskClick={() => setCheckoutOpen(false)}
bodyClassName="mobile-order-sheet"
>
<div className="mobile-order-sheet__title">发起结账</div>
<p className="mobile-order-sheet__hint">
结账金额由双方协商,提交后进入对方确认或还价流程;押金不足时需修改方案或发起争议。
</p>
<div className="mobile-order-sheet__field">
<span>结账备注</span>
<TextArea
value={checkoutContent}
onChange={setCheckoutContent}
placeholder="说明账号使用情况、消耗品、哈夫币与费用依据"
rows={4}
maxLength={500}
/>
</div>
<div className="mobile-order-sheet__grid">
<label>
<span>消耗品金额(元)</span>
<Input type="number" inputMode="decimal" value={consumableAmount} onChange={setConsumableAmount} />
</label>
<label>
<span>哈夫币消耗(M)</span>
<Input type="number" inputMode="decimal" value={coinConsumed} onChange={setCoinConsumed} />
</label>
<label>
<span>押金扣除(元)</span>
<Input type="number" inputMode="decimal" value={otherAmount} onChange={setOtherAmount} />
</label>
</div>
<div className="mobile-order-sheet__field">
<span>证据链接(每行一条,可选)</span>
<TextArea
value={checkoutEvidence}
onChange={setCheckoutEvidence}
placeholder="https://..."
rows={3}
/>
</div>
<div className="mobile-order-sheet__actions">
<Button block onClick={() => setCheckoutOpen(false)}>
取消
</Button>
<Button block color="primary" loading={busy === 'checkout'} onClick={() => void submitCheckoutAction()}>
提交结账
</Button>
</div>
</Popup>
<Popup
visible={counterOpen}
position="bottom"
onMaskClick={() => setCounterOpen(false)}
bodyClassName="mobile-order-sheet"
>
<div className="mobile-order-sheet__title">修改结账方案</div>
<p className="mobile-order-sheet__hint">
当前第 {checkout?.round_count ?? 1}/{checkout?.max_rounds ?? 6} 轮,修改后由对方继续确认或还价。
</p>
<div className="mobile-order-sheet__grid">
<label>
<span>消耗品金额(元)</span>
<Input type="number" inputMode="decimal" value={counterConsumable} onChange={setCounterConsumable} />
</label>
<label>
<span>哈夫币消耗(M)</span>
<Input type="number" inputMode="decimal" value={counterCoin} onChange={setCounterCoin} />
</label>
<label>
<span>押金扣除(元)</span>
<Input type="number" inputMode="decimal" value={counterDeduct} onChange={setCounterDeduct} />
</label>
</div>
<div className="mobile-order-sheet__field">
<span>修改原因</span>
<TextArea
value={counterReason}
onChange={setCounterReason}
placeholder="说明为什么需要调整金额或扣除押金"
rows={3}
maxLength={300}
/>
</div>
<div className="mobile-order-sheet__field">
<span>证据链接(每行一条,可选)</span>
<TextArea
value={counterEvidence}
onChange={setCounterEvidence}
placeholder="https://..."
rows={2}
/>
</div>
<div className="mobile-order-sheet__actions">
<Button block onClick={() => setCounterOpen(false)}>
取消
</Button>
<Button block color="primary" loading={busy === 'counter'} onClick={() => void counterCheckoutAction()}>
提交修改
</Button>
</div>
</Popup>
<Popup
visible={disputeOpen}
position="bottom"
onMaskClick={() => setDisputeOpen(false)}
bodyClassName="mobile-order-sheet"
>
<div className="mobile-order-sheet__title">
{checkoutDisputeStage ? '发起结账争议' : '发起申诉'}
</div>
<p className="mobile-order-sheet__hint">
提交后订单进入仲裁,客服会结合双方说明与证据处理;请尽量提供截图链接。
</p>
{!checkoutDisputeStage ? (
<div className="mobile-order-sheet__field">
<span>申诉类型</span>
<Selector
options={disputeTypes}
value={[disputeType]}
onChange={values => setDisputeType(values[0] ?? 'cannot_login')}
/>
</div>
) : null}
<div className="mobile-order-sheet__field">
<span>申诉说明</span>
<TextArea
value={disputeDescription}
onChange={setDisputeDescription}
placeholder="写清时间、经过、诉求与已经沟通的结果"
rows={5}
maxLength={1000}
showCount
/>
</div>
<div className="mobile-order-sheet__field">
<span>证据链接(每行一条,可选)</span>
<TextArea
value={disputeEvidence}
onChange={setDisputeEvidence}
placeholder="https://..."
rows={3}
/>
</div>
<div className="mobile-order-sheet__actions">
<Button block onClick={() => setDisputeOpen(false)}>
取消
</Button>
<Button block color="danger" loading={busy === 'dispute'} onClick={() => void createDisputeAction()}>
提交申诉
</Button>
</div>
</Popup>
</section>
)
}
@@ -1,11 +1,12 @@
import { useState, type ReactNode } from 'react'
import { Button, Dialog, ErrorBlock, List, Skeleton, Tag, Toast } from 'antd-mobile'
import type { ReactNode } from 'react'
import { Button, ErrorBlock, List, Skeleton, Tag } from 'antd-mobile'
import { LeftOutline } from 'antd-mobile-icons'
import { useQueryClient } from '@tanstack/react-query'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { BrandLogo } from '@/components/BrandLogo'
import { cancelMyOrder, useMyOrder } from '@/features/orders/api'
import { useHandoffRecords, useMyOrder } from '@/features/orders/api'
import { MobileOrderActionPanel } from '@/features/mobile/MobileOrderActionPanel'
import {
checkoutStatusMeta,
handoffStatusMeta,
@@ -15,6 +16,13 @@ import {
import { useSessionStore } from '@/stores/session'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const handoffRecordTypeMap: Record<string, string> = {
owner_handoff: '号主交接',
platform_handoff: '平台代交接',
renter_checkout: '结账记录',
renter_return: '归还记录',
}
function StatusLine({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="mobile-order-detail__status-line">
@@ -31,32 +39,9 @@ export function MobileOrderDetailPage() {
const token = useSessionStore(state => state.token)
const hydrated = useSessionStore(state => state.hydrated)
const query = useMyOrder(id, Boolean(token))
const [cancelling, setCancelling] = useState(false)
const handoffQuery = useHandoffRecords(id, Boolean(token))
const order = query.data
const onCancel = async () => {
if (!order) return
const confirmed = await Dialog.confirm({
content: '取消后订单会关闭,已冻结的押金将按原路退回。确定取消吗?',
confirmText: '取消订单',
cancelText: '再想想',
})
if (!confirmed) return
setCancelling(true)
try {
await cancelMyOrder(order.id)
Toast.show({ icon: 'success', content: '订单已取消' })
await queryClient.invalidateQueries({ queryKey: ['my-orders'] })
await query.refetch()
} catch (error) {
Toast.show({
icon: 'fail',
content: error instanceof Error ? error.message : '取消失败,请稍后再试',
})
} finally {
setCancelling(false)
}
}
const handoffRecords = handoffQuery.data ?? []
if (!hydrated) {
return (
@@ -121,7 +106,6 @@ export function MobileOrderDetailPage() {
const handoff = handoffStatusMeta(order.handoff_status)
const settlement = settlementStatusMeta(order.settlement_status)
const checkout = order.checkout
const canCancel = order.price_role === 'renter' && order.status === 'pending_payment'
return (
<div className="mobile-order-detail">
@@ -192,12 +176,22 @@ export function MobileOrderDetailPage() {
结账状态
</List.Item>
<List.Item extra={`${checkout.round_count} / ${checkout.max_rounds}`}>协商轮次</List.Item>
{checkout.turn ? (
<List.Item extra={checkout.turn === 'owner' ? '号主处理' : '租客处理'}>
当前轮到
</List.Item>
) : null}
<List.Item extra={formatMoney(checkout.consumable_amount_cent)}>消耗品费用</List.Item>
<List.Item extra={`${checkout.coin_consumed_m} M`}>哈夫币消耗</List.Item>
<List.Item extra={formatMoney(checkout.other_amount_cent)}>其他费用</List.Item>
<List.Item extra={formatMoney(checkout.deposit_deduct_amount_cent)}>押金扣除</List.Item>
<List.Item extra={formatMoney(checkout.renter_refund_amount_cent)}>可退金额</List.Item>
<List.Item extra={formatMoney(checkout.owner_income_amount_cent)}>号主到手</List.Item>
{checkout.shortfall_cent > 0 ? (
<List.Item extra={<span className="mobile-order-detail__danger">{formatMoney(checkout.shortfall_cent)}</span>}>
押金缺口
</List.Item>
) : null}
{checkout.content ? (
<List.Item>
<div className="mobile-order-detail__checkout-content">{checkout.content}</div>
@@ -214,12 +208,34 @@ export function MobileOrderDetailPage() {
</List>
) : null}
{handoffRecords.length > 0 ? (
<List header="交接与结账记录" className="mobile-order-detail__list">
{handoffRecords.map(record => (
<List.Item key={record.id}>
<div className="mobile-order-detail__record">
<div className="mobile-order-detail__record-head">
<strong>{handoffRecordTypeMap[record.type] ?? record.type}</strong>
<span>{formatDateTime(record.created_at)}</span>
</div>
<p>{record.content}</p>
<div className="mobile-order-detail__record-tags">
{record.confirmed_by_renter_at ? <Tag color="success">租客已确认</Tag> : null}
{record.confirmed_by_owner_at ? <Tag color="success">号主已确认</Tag> : null}
</div>
</div>
</List.Item>
))}
</List>
) : null}
<div className="mobile-order-detail__actions">
{canCancel ? (
<Button block color="danger" fill="outline" loading={cancelling} onClick={() => void onCancel()}>
取消订单
</Button>
) : null}
<MobileOrderActionPanel
order={order}
onChanged={async () => {
await Promise.all([query.refetch(), handoffQuery.refetch()])
await queryClient.invalidateQueries({ queryKey: ['my-orders'] })
}}
/>
<Link to="/m/orders" className="mobile-order-detail__back-link">
返回我的订单
</Link>
+72 -1
View File
@@ -2,7 +2,15 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { apiClient, unwrapData } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/api/types'
import type { UserOrderDetail, UserOrderPage } from '@/features/orders/types'
import type {
CounterCheckoutPayload,
CreateDisputePayload,
SubmitCheckoutPayload,
UserHandoffRecord,
UserOrderDetail,
UserOrderPage,
UserPayment,
} from '@/features/orders/types'
export const MY_ORDER_PAGE_SIZE = 20
@@ -45,3 +53,66 @@ export function useMyOrder(id?: string, enabled = true) {
export async function cancelMyOrder(id: number | string) {
await apiClient.post(`/orders/${id}/cancel`)
}
export async function startOrderPayment(id: number | string, payWay: 'WXZF' | 'ZFBZF') {
return unwrapData<UserPayment>(
apiClient.post<ApiResponse<UserPayment>>(`/orders/${id}/start-payment`, {
pay_way: payWay,
jspay_flag: '',
})
)
}
export async function queryOrderPayment(id: number | string) {
return unwrapData<UserPayment>(
apiClient.get<ApiResponse<UserPayment>>(`/orders/${id}/query-payment`)
)
}
export async function submitHandoff(id: number | string, content: string) {
return unwrapData<UserHandoffRecord>(
apiClient.post<ApiResponse<UserHandoffRecord>>(`/orders/${id}/handoff`, { content })
)
}
export async function confirmReceive(id: number | string) {
await apiClient.post(`/orders/${id}/confirm-receive`)
}
export function fetchHandoffRecords(id: number | string) {
return unwrapData<{ items?: UserHandoffRecord[] }>(
apiClient.get<ApiResponse<{ items?: UserHandoffRecord[] }>>(`/orders/${id}/handoff-records`)
).then(data => data.items ?? [])
}
export function useHandoffRecords(id?: string, enabled = true) {
return useQuery({
queryKey: ['my-order-handoffs', id],
queryFn: () => fetchHandoffRecords(id as string),
enabled: Boolean(id) && enabled,
})
}
export async function submitCheckout(id: number | string, payload: SubmitCheckoutPayload) {
await apiClient.post(`/orders/${id}/checkout`, payload)
}
export async function confirmCheckout(id: number | string) {
await apiClient.post(`/orders/${id}/checkout/confirm`)
}
export async function counterCheckout(id: number | string, payload: CounterCheckoutPayload) {
await apiClient.post(`/orders/${id}/checkout/counter`, payload)
}
export async function acceptCheckout(id: number | string) {
await apiClient.post(`/orders/${id}/checkout/accept`)
}
export async function createOrderDispute(id: number | string, payload: CreateDisputePayload) {
await apiClient.post(`/orders/${id}/dispute`, payload)
}
export async function cancelOrderDispute(id: number | string) {
await apiClient.post(`/orders/${id}/dispute/cancel`)
}
+55
View File
@@ -32,7 +32,12 @@ export interface UserCheckout {
status: string
round_count: number
max_rounds: number
turn?: string
proposed_by?: number
can_counter?: boolean
can_accept?: boolean
content: string
evidence_urls?: string[]
consumable_amount_cent: number
coin_consumed_m: number
other_amount_cent: number
@@ -51,7 +56,9 @@ export interface UserActiveDispute {
id: number
type: string
status: string
initiator_id: number
initiator_type: string
initiator_admin_id?: number | null
}
export interface UserOrderDetail {
@@ -87,3 +94,51 @@ export interface UserOrderDetail {
created_at: string
updated_at: string
}
export interface UserHandoffRecord {
id: number
order_id: number
from_user_id: number
to_user_id: number
type: string
content: string
confirmed_by_renter_at?: string | null
confirmed_by_owner_at?: string | null
created_at: string
}
export interface UserPayment {
id: number
payment_no: string
order_id: number
order_no: string
provider: string
pay_way: string
amount_cent: number
status: string
td_code?: string
jspay_url?: string
jspay_info?: string
paid: boolean
paid_at?: string | null
created_at: string
}
export interface SubmitCheckoutPayload {
content: string
consumable_amount_cent: number
coin_consumed_m: number
other_amount_cent: number
evidence_urls: string[]
}
export interface CounterCheckoutPayload extends SubmitCheckoutPayload {
deposit_deduct_amount_cent: number
reason: string
}
export interface CreateDisputePayload {
type: string
description: string
evidence_urls: string[]
}
+141
View File
@@ -2518,6 +2518,147 @@ a {
text-align: center;
}
.mobile-order-actions {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--paper-warm);
}
.mobile-order-actions__head {
display: flex;
align-items: baseline;
justify-content: space-between;
color: var(--brand-ink);
font-size: 11px;
letter-spacing: 0.12em;
}
.mobile-order-actions__head em {
color: var(--brand-seal);
font-style: normal;
letter-spacing: 0;
}
.mobile-order-actions__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.mobile-order-detail__record {
padding: 4px 0;
}
.mobile-order-detail__record-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.mobile-order-detail__record-head strong {
color: var(--brand-ink);
font-size: 12px;
}
.mobile-order-detail__record-head span {
color: var(--ink-muted);
font-size: 10px;
}
.mobile-order-detail__record p {
margin: 6px 0;
color: var(--ink-secondary);
font-size: 12px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.mobile-order-detail__record-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.mobile-order-detail__danger {
color: var(--brand-seal);
font-family: 'Inter', monospace;
}
.mobile-order-sheet {
max-height: 88vh;
padding: 20px 16px calc(20px + env(safe-area-inset-bottom));
overflow-y: auto;
border-radius: 16px 16px 0 0;
background: var(--paper-warm);
}
.mobile-order-sheet__title {
margin-bottom: 6px;
color: var(--brand-ink);
font-size: 18px;
font-weight: 700;
}
.mobile-order-sheet__hint {
margin: 0 0 14px;
color: var(--ink-muted);
font-size: 11px;
line-height: 1.6;
}
.mobile-order-sheet__field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px;
color: var(--ink-muted);
font-size: 11px;
}
.mobile-order-sheet__field .adm-text-area {
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.mobile-order-sheet__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 12px;
margin-bottom: 14px;
}
.mobile-order-sheet__grid label {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--ink-muted);
font-size: 11px;
}
.mobile-order-sheet__grid .adm-input {
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
font-family: 'Inter', monospace;
font-size: 13px;
}
.mobile-order-sheet__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 18px;
}
.mobile-messages {
padding: 20px 16px 32px;
}