Files
hfb_jysh/frontend/src/features/orders/OrderActionPanel.tsx
T

665 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
import {
Alert,
App,
Button,
Card,
Form,
Input,
InputNumber,
Modal,
QRCode,
Radio,
Select,
Space,
Tag,
Typography,
} from 'antd'
import { EvidenceUpload } from '@/components/EvidenceUpload'
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 { confirmModal } from '@/shared/utils/confirm'
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
evidenceUrls?: string[]
disputeType?: string
}
interface OrderActionPanelProps {
order: UserOrderDetail
onChanged: () => void | Promise<void>
autoPay?: boolean
onAutoPayHandled?: () => void
}
export function OrderActionPanel({
order,
onChanged,
autoPay = false,
onAutoPayHandled,
}: 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 autoPayTriggered = useRef(false)
const payPolling = useRef<number | null>(null)
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 evidencePayload = (values: ActionFormValues) => [
...(values.evidenceUrls ?? []),
...splitEvidenceLines(values.evidenceText ?? ''),
]
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: '',
evidenceUrls: [],
})
}
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: '',
evidenceUrls: [],
})
}
if (kind === 'dispute') {
form.setFieldsValue({
disputeType: actions.checkoutDisputeStage ? 'checkout_dispute' : 'cannot_login',
content: '',
evidenceText: '',
evidenceUrls: [],
})
}
setDialog(kind)
}
useEffect(() => {
if (!autoPay || autoPayTriggered.current || !actions.canPay) return
autoPayTriggered.current = true
openDialog('pay')
onAutoPayHandled?.()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoPay, actions.canPay])
useEffect(() => {
if (dialog !== 'pay' || !payURL) return
let cancelled = false
let attempts = 0
const poll = async () => {
if (cancelled) return
attempts += 1
try {
const payment = await queryOrderPayment(order.id)
if (cancelled) return
if (payment.paid) {
message.success('支付成功,订单已进入待交接')
setDialog(null)
setPayURL('')
await onChanged()
return
}
} catch {
// 自动轮询不打断用户;保留手动检查作为明确反馈。
}
if (!cancelled && attempts < 200) {
payPolling.current = window.setTimeout(() => void poll(), 3000)
}
}
payPolling.current = window.setTimeout(() => void poll(), 3000)
return () => {
cancelled = true
if (payPolling.current !== null) {
window.clearTimeout(payPolling.current)
payPolling.current = null
}
}
}, [dialog, message, onChanged, order.id, payURL])
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 confirmModal(modal, {
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 confirmModal(modal, {
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: evidencePayload(values),
})
message.success('结账已发起,等待对方确认')
})
}
const submitConfirmCheckout = async () => {
if ((checkout?.shortfall_cent ?? 0) > 0) {
message.warning('押金不足,请修改方案或发起争议')
return
}
const confirmed = await confirmModal(modal, {
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 confirmModal(modal, {
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: evidencePayload(values),
})
message.success('结账方案已修改,等待对方处理')
})
}
const submitDispute = async () => {
const values = await readForm()
if (!values) return
const confirmed = await confirmModal(modal, {
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: evidencePayload(values),
})
message.success('申诉已提交')
})
}
const cancelDispute = async () => {
const confirmed = await confirmModal(modal, {
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 confirmModal(modal, {
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" align="center">
<QRCode value={payURL} size={180} />
<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="evidenceUrls" label="证据图片(可选)">
<EvidenceUpload scene="handoff" max={6} />
</Form.Item>
<Form.Item name="evidenceText" label="外部证据链接(每行一条,可选)">
<Input.TextArea rows={2} 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="evidenceUrls" label="证据图片(可选)">
<EvidenceUpload scene="dispute" max={6} />
</Form.Item>
<Form.Item name="evidenceText" label="外部证据链接(每行一条,可选)">
<Input.TextArea rows={2} placeholder="https://..." />
</Form.Item>
</Form>
) : null}
</Modal>
</Card>
)
}