feat(storefront): 新增 PC 订单中心与订单操作

This commit is contained in:
yml2213
2026-10-30 13:56:44 +08:00
parent 55e914edd1
commit e13eda20a2
13 changed files with 1668 additions and 101 deletions
@@ -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>
)
}