feat(storefront): 新增 PC 订单中心与订单操作
This commit is contained in:
@@ -0,0 +1,592 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
|
||||
import {
|
||||
acceptCheckout,
|
||||
cancelMyOrder,
|
||||
cancelOrderDispute,
|
||||
confirmCheckout,
|
||||
confirmReceive,
|
||||
counterCheckout as counterCheckoutApi,
|
||||
createOrderDispute,
|
||||
queryOrderPayment,
|
||||
startOrderPayment,
|
||||
submitCheckout as submitCheckoutApi,
|
||||
submitHandoff as submitHandoffApi,
|
||||
} from '@/features/orders/api'
|
||||
import {
|
||||
DISPUTE_TYPES,
|
||||
getOrderActionAvailability,
|
||||
splitEvidenceLines,
|
||||
toPositiveNumber,
|
||||
yuanToCent,
|
||||
} from '@/features/orders/orderActions'
|
||||
import type { UserOrderDetail } from '@/features/orders/types'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { formatMoney } from '@/shared/utils/format'
|
||||
|
||||
type DialogKind = 'pay' | 'handoff' | 'checkout' | 'counter' | 'dispute' | null
|
||||
|
||||
interface ActionFormValues {
|
||||
payWay?: 'WXZF' | 'ZFBZF'
|
||||
content?: string
|
||||
consumableYuan?: number
|
||||
coinConsumed?: number
|
||||
otherYuan?: number
|
||||
reason?: string
|
||||
evidenceText?: string
|
||||
disputeType?: string
|
||||
}
|
||||
|
||||
interface OrderActionPanelProps {
|
||||
order: UserOrderDetail
|
||||
onChanged: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function OrderActionPanel({ order, onChanged }: OrderActionPanelProps) {
|
||||
const { message, modal } = App.useApp()
|
||||
const [form] = Form.useForm<ActionFormValues>()
|
||||
const user = useSessionStore(state => state.user)
|
||||
const [dialog, setDialog] = useState<DialogKind>(null)
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [payURL, setPayURL] = useState('')
|
||||
const actions = getOrderActionAvailability(order, user?.id)
|
||||
const checkout = order.checkout
|
||||
|
||||
const closeDialog = () => {
|
||||
if (busy) return
|
||||
setDialog(null)
|
||||
setPayURL('')
|
||||
form.resetFields()
|
||||
}
|
||||
|
||||
const readForm = async (): Promise<ActionFormValues | null> => {
|
||||
try {
|
||||
return await form.validateFields()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const openDialog = (kind: Exclude<DialogKind, null>) => {
|
||||
form.resetFields()
|
||||
setPayURL('')
|
||||
if (kind === 'pay') {
|
||||
form.setFieldsValue({ payWay: 'WXZF' })
|
||||
}
|
||||
if (kind === 'checkout') {
|
||||
form.setFieldsValue({
|
||||
content: '',
|
||||
consumableYuan: 0,
|
||||
coinConsumed: checkout?.coin_consumed_m ?? 0,
|
||||
otherYuan: 0,
|
||||
evidenceText: '',
|
||||
})
|
||||
}
|
||||
if (kind === 'counter') {
|
||||
form.setFieldsValue({
|
||||
consumableYuan: (checkout?.consumable_amount_cent ?? 0) / 100,
|
||||
coinConsumed: checkout?.coin_consumed_m ?? 0,
|
||||
otherYuan: (checkout?.deposit_deduct_amount_cent ?? 0) / 100,
|
||||
reason: '',
|
||||
evidenceText: '',
|
||||
})
|
||||
}
|
||||
if (kind === 'dispute') {
|
||||
form.setFieldsValue({
|
||||
disputeType: actions.checkoutDisputeStage ? 'checkout_dispute' : 'cannot_login',
|
||||
content: '',
|
||||
evidenceText: '',
|
||||
})
|
||||
}
|
||||
setDialog(kind)
|
||||
}
|
||||
|
||||
const run = async (key: string, fn: () => Promise<void>) => {
|
||||
setBusy(key)
|
||||
try {
|
||||
await fn()
|
||||
await onChanged()
|
||||
setDialog(null)
|
||||
setPayURL('')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '操作失败,请稍后再试')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const submitPay = async () => {
|
||||
const values = await readForm()
|
||||
if (!values) return
|
||||
setBusy('pay')
|
||||
try {
|
||||
const payment = await startOrderPayment(order.id, values.payWay ?? 'WXZF')
|
||||
if (payment.paid) {
|
||||
message.success('支付成功')
|
||||
await onChanged()
|
||||
setDialog(null)
|
||||
setPayURL('')
|
||||
return
|
||||
}
|
||||
const url = payment.jspay_url || payment.td_code || payment.jspay_info || ''
|
||||
if (url) {
|
||||
setPayURL(url)
|
||||
message.info('支付单已创建,请打开支付页面完成付款')
|
||||
} else {
|
||||
message.warning('支付单已创建,但渠道没有返回支付链接,请稍后检查支付状态')
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '发起支付失败')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const checkPayment = async () => {
|
||||
setBusy('check-pay')
|
||||
try {
|
||||
const payment = await queryOrderPayment(order.id)
|
||||
if (payment.paid) {
|
||||
message.success('支付成功,订单已进入待交接')
|
||||
setDialog(null)
|
||||
setPayURL('')
|
||||
await onChanged()
|
||||
return
|
||||
}
|
||||
message.info('支付暂未完成,请稍后再试')
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '查询支付状态失败')
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const submitHandoff = async () => {
|
||||
const values = await readForm()
|
||||
if (!values) return
|
||||
await run('handoff', async () => {
|
||||
await submitHandoffApi(order.id, values.content ?? '')
|
||||
message.success('交接说明已提交')
|
||||
})
|
||||
}
|
||||
|
||||
const submitReceive = async () => {
|
||||
const confirmed = await modal.confirm({
|
||||
title: '确认收号',
|
||||
content: '请确认账号可以正常登录、资料与交接说明一致。确认后订单进入使用中。',
|
||||
okText: '确认收号',
|
||||
cancelText: '再检查一下',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('receive', async () => {
|
||||
await confirmReceive(order.id)
|
||||
message.success('已确认收号')
|
||||
})
|
||||
}
|
||||
|
||||
const submitCheckout = async () => {
|
||||
const values = await readForm()
|
||||
if (!values) return
|
||||
const confirmed = await modal.confirm({
|
||||
title: '确认提交结账',
|
||||
content: '结账会直接影响租金、押金赔付与双方结算金额,提交后将发送给对方确认。',
|
||||
okText: '提交结账',
|
||||
cancelText: '再检查一下',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('checkout', async () => {
|
||||
await submitCheckoutApi(order.id, {
|
||||
content: values.content ?? '',
|
||||
consumable_amount_cent: yuanToCent(values.consumableYuan ?? 0),
|
||||
coin_consumed_m: toPositiveNumber(values.coinConsumed ?? 0),
|
||||
other_amount_cent: yuanToCent(values.otherYuan ?? 0),
|
||||
evidence_urls: splitEvidenceLines(values.evidenceText ?? ''),
|
||||
})
|
||||
message.success('结账已发起,等待对方确认')
|
||||
})
|
||||
}
|
||||
|
||||
const submitConfirmCheckout = async () => {
|
||||
if ((checkout?.shortfall_cent ?? 0) > 0) {
|
||||
message.warning('押金不足,请修改方案或发起争议')
|
||||
return
|
||||
}
|
||||
const confirmed = await modal.confirm({
|
||||
title: '确认结账',
|
||||
content: '确认账号状态和扣款金额无误吗?确认后订单将完成。',
|
||||
okText: '确认结账',
|
||||
cancelText: '再检查一下',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('confirm-checkout', async () => {
|
||||
await confirmCheckout(order.id)
|
||||
message.success('结账已确认,订单完成')
|
||||
})
|
||||
}
|
||||
|
||||
const submitAcceptCheckout = async () => {
|
||||
if ((checkout?.shortfall_cent ?? 0) > 0) {
|
||||
message.warning('押金不足,请修改方案或发起争议')
|
||||
return
|
||||
}
|
||||
const confirmed = await modal.confirm({
|
||||
title: '同意结账方案',
|
||||
content: '确定同意当前结账方案吗?同意后订单将完成结算。',
|
||||
okText: '同意结账',
|
||||
cancelText: '再检查一下',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('accept-checkout', async () => {
|
||||
await acceptCheckout(order.id)
|
||||
message.success('已同意结账,订单完成')
|
||||
})
|
||||
}
|
||||
|
||||
const submitCounter = async () => {
|
||||
const values = await readForm()
|
||||
if (!values) return
|
||||
await run('counter', async () => {
|
||||
await counterCheckoutApi(order.id, {
|
||||
content: values.reason ?? '',
|
||||
reason: values.reason ?? '',
|
||||
consumable_amount_cent: yuanToCent(values.consumableYuan ?? 0),
|
||||
coin_consumed_m: toPositiveNumber(values.coinConsumed ?? 0),
|
||||
other_amount_cent: yuanToCent(values.otherYuan ?? 0),
|
||||
deposit_deduct_amount_cent: yuanToCent(values.otherYuan ?? 0),
|
||||
evidence_urls: splitEvidenceLines(values.evidenceText ?? ''),
|
||||
})
|
||||
message.success('结账方案已修改,等待对方处理')
|
||||
})
|
||||
}
|
||||
|
||||
const submitDispute = async () => {
|
||||
const values = await readForm()
|
||||
if (!values) return
|
||||
const confirmed = await modal.confirm({
|
||||
title: actions.checkoutDisputeStage ? '发起结账争议' : '发起申诉',
|
||||
content: '提交后订单会进入仲裁流程,客服会根据双方证据处理。确定提交吗?',
|
||||
okText: '提交',
|
||||
cancelText: '再想想',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('dispute', async () => {
|
||||
await createOrderDispute(order.id, {
|
||||
type: actions.checkoutDisputeStage ? 'checkout_dispute' : values.disputeType ?? 'other',
|
||||
description: values.content ?? '',
|
||||
evidence_urls: splitEvidenceLines(values.evidenceText ?? ''),
|
||||
})
|
||||
message.success('申诉已提交')
|
||||
})
|
||||
}
|
||||
|
||||
const cancelDispute = async () => {
|
||||
const confirmed = await modal.confirm({
|
||||
title: '取消申诉',
|
||||
content: '取消后订单会恢复到发起申诉前的流程,确定继续吗?',
|
||||
okText: '取消申诉',
|
||||
cancelText: '保留申诉',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('cancel-dispute', async () => {
|
||||
await cancelOrderDispute(order.id)
|
||||
message.success('申诉已取消')
|
||||
})
|
||||
}
|
||||
|
||||
const cancelOrder = async () => {
|
||||
const refundReview = order.status === 'pending_handoff'
|
||||
const confirmed = await modal.confirm({
|
||||
title: refundReview ? '申请取消订单' : '取消订单',
|
||||
content: refundReview
|
||||
? '订单已支付,取消后需要客服审核退款,审核通过前账号仍会保持占用。'
|
||||
: '取消后订单会关闭,已冻结的押金将按原路退回。',
|
||||
okText: refundReview ? '申请取消' : '取消订单',
|
||||
cancelText: '再想想',
|
||||
})
|
||||
if (!confirmed) return
|
||||
await run('cancel', async () => {
|
||||
await cancelMyOrder(order.id)
|
||||
message.success(refundReview ? '取消申请已提交' : '订单已取消')
|
||||
})
|
||||
}
|
||||
|
||||
const dialogTitle: Record<Exclude<DialogKind, null>, string> = {
|
||||
pay: '支付订单',
|
||||
handoff: '提交交接说明',
|
||||
checkout: '发起结账',
|
||||
counter: '修改结账方案',
|
||||
dispute: actions.checkoutDisputeStage ? '发起结账争议' : '发起申诉',
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="订单操作" className="pc-order-action-card">
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{actions.canPay ? (
|
||||
<Space wrap>
|
||||
<Button type="primary" onClick={() => openDialog('pay')}>
|
||||
去支付
|
||||
</Button>
|
||||
<Button loading={busy === 'check-pay'} onClick={() => void checkPayment()}>
|
||||
检查支付状态
|
||||
</Button>
|
||||
</Space>
|
||||
) : null}
|
||||
|
||||
{actions.canSubmitHandoff ? (
|
||||
<Button type="primary" onClick={() => openDialog('handoff')}>
|
||||
提交交接说明
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{actions.canConfirmReceive ? (
|
||||
<Button type="primary" loading={busy === 'receive'} onClick={() => void submitReceive()}>
|
||||
确认收号
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{actions.canSubmitCheckout ? (
|
||||
<Button type="primary" onClick={() => openDialog('checkout')}>
|
||||
发起结账
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{actions.canConfirmCheckout ? (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={busy === 'confirm-checkout'}
|
||||
onClick={() => void submitConfirmCheckout()}
|
||||
>
|
||||
确认结账
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{actions.canAcceptCheckout ? (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={busy === 'accept-checkout'}
|
||||
onClick={() => void submitAcceptCheckout()}
|
||||
>
|
||||
同意结账方案
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Space wrap>
|
||||
{actions.canCounterCheckout ? (
|
||||
<Button onClick={() => openDialog('counter')}>修改结账方案</Button>
|
||||
) : null}
|
||||
{actions.canOpenDispute ? (
|
||||
<Button danger onClick={() => openDialog('dispute')}>
|
||||
{actions.checkoutDisputeStage ? '拒绝方案并发起争议' : '发起申诉'}
|
||||
</Button>
|
||||
) : null}
|
||||
{actions.canCancelDispute ? (
|
||||
<Button danger loading={busy === 'cancel-dispute'} onClick={() => void cancelDispute()}>
|
||||
取消申诉
|
||||
</Button>
|
||||
) : null}
|
||||
{actions.canCancelOrder ? (
|
||||
<Button danger loading={busy === 'cancel'} onClick={() => void cancelOrder()}>
|
||||
取消订单
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
{!actions.hasAction ? (
|
||||
<Typography.Text type="secondary">当前订单没有可执行操作。</Typography.Text>
|
||||
) : null}
|
||||
|
||||
{checkout && checkout.shortfall_cent > 0 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={`押金缺口 ${formatMoney(checkout.shortfall_cent)}`}
|
||||
description="押金不足以覆盖当前方案,需修改方案或发起争议由客服处理。"
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
open={Boolean(dialog)}
|
||||
title={dialog ? dialogTitle[dialog] : ''}
|
||||
width={640}
|
||||
onCancel={closeDialog}
|
||||
maskClosable={!busy}
|
||||
confirmLoading={Boolean(busy)}
|
||||
footer={
|
||||
dialog === 'pay' && payURL ? (
|
||||
<Space>
|
||||
<Button onClick={closeDialog} disabled={Boolean(busy)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button loading={busy === 'check-pay'} onClick={() => void checkPayment()}>
|
||||
检查支付状态
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<Button onClick={closeDialog} disabled={Boolean(busy)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={Boolean(busy)}
|
||||
onClick={() => {
|
||||
if (dialog === 'pay') void submitPay()
|
||||
if (dialog === 'handoff') void submitHandoff()
|
||||
if (dialog === 'checkout') void submitCheckout()
|
||||
if (dialog === 'counter') void submitCounter()
|
||||
if (dialog === 'dispute') void submitDispute()
|
||||
}}
|
||||
>
|
||||
{dialog === 'pay' ? '发起支付' : dialog === 'handoff' ? '提交交接' : '提交'}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{dialog === 'pay' ? (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="选择支付方式"
|
||||
description="支付成功后才进入交接流程;支付完成后回到本页点击「检查支付状态」。"
|
||||
/>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="payWay" label="支付方式">
|
||||
<Radio.Group>
|
||||
<Radio.Button value="WXZF">微信支付</Radio.Button>
|
||||
<Radio.Button value="ZFBZF">支付宝</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{payURL ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
message="支付单已创建"
|
||||
description={
|
||||
<Space direction="vertical">
|
||||
<span>请在支付页面完成付款,完成后回到本页检查支付状态。</span>
|
||||
<Button type="primary" href={payURL} target="_blank">
|
||||
打开支付页面
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{dialog === 'handoff' ? (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="交接说明会展示给租客"
|
||||
description="请写清账号、密码、区服、注意事项与可登录时间。"
|
||||
/>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="交接说明"
|
||||
rules={[{ required: true, message: '请填写交接说明' }]}
|
||||
>
|
||||
<Input.TextArea rows={6} maxLength={1000} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{dialog === 'checkout' || dialog === 'counter' ? (
|
||||
<Form form={form} layout="vertical">
|
||||
{dialog === 'counter' ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`第 ${checkout?.round_count ?? 1}/${checkout?.max_rounds ?? 6} 轮协商`}
|
||||
description="修改后由对方继续确认或还价,最多 6 轮。"
|
||||
/>
|
||||
) : null}
|
||||
<Form.Item
|
||||
name={dialog === 'counter' ? 'reason' : 'content'}
|
||||
label={dialog === 'counter' ? '修改原因' : '结账备注'}
|
||||
rules={[{ required: true, message: '请填写说明' }]}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
showCount
|
||||
placeholder={
|
||||
dialog === 'counter'
|
||||
? '说明为什么需要调整金额或扣除押金'
|
||||
: '说明账号使用情况、消耗品、哈夫币与费用依据'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Space size={12} style={{ width: '100%' }} align="start">
|
||||
<Form.Item name="consumableYuan" label="消耗品金额(元)">
|
||||
<InputNumber min={0} precision={2} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="coinConsumed" label="哈夫币消耗(M)">
|
||||
<InputNumber min={0} precision={2} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="otherYuan" label="押金扣除(元)">
|
||||
<InputNumber min={0} precision={2} style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="evidenceText" label="证据链接(每行一条,可选)">
|
||||
<Input.TextArea rows={3} placeholder="https://..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
|
||||
{dialog === 'dispute' ? (
|
||||
<Form form={form} layout="vertical">
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="提交后进入仲裁"
|
||||
description="请写清时间、经过、诉求与已经沟通的结果,并尽量附上证据链接。"
|
||||
/>
|
||||
{!actions.checkoutDisputeStage ? (
|
||||
<Form.Item name="disputeType" label="申诉类型" style={{ marginTop: 16 }}>
|
||||
<Select options={DISPUTE_TYPES} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Tag color="red" className="pc-order-dispute-tag">
|
||||
结账争议
|
||||
</Tag>
|
||||
)}
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="申诉说明"
|
||||
rules={[{ required: true, message: '请填写申诉说明' }]}
|
||||
>
|
||||
<Input.TextArea rows={5} maxLength={1000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="evidenceText" label="证据链接(每行一条,可选)">
|
||||
<Input.TextArea rows={3} placeholder="https://..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user