feat(mobile): 接入订单支付交接结账与申诉操作
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user