Files
affiliate_dash/frontend/src/pages/Delivery.tsx
T
2026-07-31 12:57:17 +08:00

262 lines
9.3 KiB
TypeScript

import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import {
Alert,
Button,
Descriptions,
Form,
Image,
Input,
Result,
Space,
Spin,
Steps,
Tag,
Typography,
message,
} from 'antd'
import {
CheckCircleOutlined,
ClockCircleOutlined,
QrcodeOutlined,
SendOutlined,
} from '@ant-design/icons'
import { deliveryApi } from '../api'
import type { DeliveryBindResult, DeliveryOrderInfo, DeliverySubmitResult } from '../types'
import { formatDateTime } from '../utils/time'
const statusMap: Record<string, { color: string; text: string }> = {
paid: { color: 'orange', text: '待发货' },
delivering: { color: 'blue', text: '履约中' },
delivered: { color: 'green', text: '已交付' },
ship_failed: { color: 'red', text: '发货失败' },
cancelled: { color: 'default', text: '已取消' },
}
export default function Delivery() {
const { channel = 'dlc', orderNo = '' } = useParams()
const [searchParams] = useSearchParams()
const [form] = Form.useForm<{ game_account: string }>()
const [order, setOrder] = useState<DeliveryOrderInfo | null>(null)
const [bindResult, setBindResult] = useState<DeliveryBindResult | null>(null)
const [submitResult, setSubmitResult] = useState<DeliverySubmitResult | null>(null)
const [loading, setLoading] = useState(true)
const [binding, setBinding] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const decodedOrderNo = useMemo(() => decodeURIComponent(orderNo), [orderNo])
const deliveryAuth = useMemo(() => {
const exp = searchParams.get('exp') || ''
const sign = searchParams.get('sign') || ''
return exp && sign ? { exp, sign } : undefined
}, [searchParams])
const gameAccount = Form.useWatch('game_account', form)
useEffect(() => {
if (!decodedOrderNo) {
setError('发货链接缺少订单号')
setLoading(false)
return
}
setLoading(true)
deliveryApi.getOrder(decodedOrderNo, deliveryAuth)
.then((data) => {
setOrder(data)
setError('')
})
.catch((e) => setError(e instanceof Error ? e.message : '订单加载失败'))
.finally(() => setLoading(false))
}, [decodedOrderNo, deliveryAuth])
const bindAccount = async () => {
const values = await form.validateFields()
setBinding(true)
setSubmitResult(null)
try {
const result = await deliveryApi.bind(decodedOrderNo, values.game_account, deliveryAuth)
setBindResult(result)
message.success('绑定二维码已生成')
} catch (e) {
message.error(e instanceof Error ? e.message : '生成二维码失败')
} finally {
setBinding(false)
}
}
const submitDelivery = async () => {
const values = await form.validateFields()
if (!bindResult?.bind_uuid) {
message.warning('请先生成绑定二维码')
return
}
setSubmitting(true)
try {
const result = await deliveryApi.submit(decodedOrderNo, values.game_account, bindResult.bind_uuid, deliveryAuth)
setSubmitResult(result)
setOrder((prev) => prev ? { ...prev, status: result.status, can_ship: false } : prev)
message.success(result.message || '已提交')
} catch (e) {
message.error(e instanceof Error ? e.message : '提交失败')
} finally {
setSubmitting(false)
}
}
if (loading) {
return (
<CenteredShell>
<Spin size="large" />
</CenteredShell>
)
}
if (error || !order) {
return (
<CenteredShell>
<Result status="warning" title="订单不可发货" subTitle={error || '订单不存在'} />
</CenteredShell>
)
}
const status = statusMap[order.status] || { color: 'default', text: order.status }
const step = submitResult ? 2 : bindResult ? 1 : 0
return (
<main style={{ minHeight: '100vh', background: '#f5f7fb', padding: '32px 16px' }}>
<div style={{ maxWidth: 880, margin: '0 auto' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<header>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
游戏道具发货
</Typography.Title>
<Typography.Text type="secondary">
{channel.toUpperCase()} · {decodedOrderNo}
</Typography.Text>
</header>
<section style={panelStyle}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space align="start" style={{ width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ marginTop: 0, marginBottom: 8 }}>
{order.product?.name || '待发货商品'}
</Typography.Title>
<Typography.Text code>{order.product?.sku || '-'}</Typography.Text>
</div>
<Tag color={status.color}>{status.text}</Tag>
</Space>
<Descriptions column={{ xs: 1, sm: 2 }} size="small" bordered>
<Descriptions.Item label="买家">{order.buyer_name || '-'}</Descriptions.Item>
<Descriptions.Item label="金额">{order.amount} 积分</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatDateTime(order.created_at)}</Descriptions.Item>
<Descriptions.Item label="渠道">{order.product?.game || channel.toUpperCase()}</Descriptions.Item>
</Descriptions>
{order.ship_fail_reason && (
<Alert type="error" showIcon message={order.ship_fail_reason} />
)}
{!order.can_ship && (
<Alert type="warning" showIcon message={order.cannot_ship_reason || '当前订单暂不可发货'} />
)}
</Space>
</section>
{order.can_ship && (
<section style={panelStyle}>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Steps
current={step}
items={[
{ title: '填写 UID', icon: <ClockCircleOutlined /> },
{ title: '扫码绑定', icon: <QrcodeOutlined /> },
{ title: '提交发货', icon: <CheckCircleOutlined /> },
]}
/>
<Form form={form} layout="vertical" initialValues={{ game_account: order.game_uid || '' }}>
<Form.Item
name="game_account"
label="游戏 UID"
rules={[
{ required: true, message: '请输入游戏 UID' },
{ pattern: /^\d+$/, message: '游戏 UID 仅支持数字' },
]}
>
<Input size="large" inputMode="numeric" placeholder="请输入游戏 UID" />
</Form.Item>
</Form>
<Space wrap>
<Button
type="primary"
icon={<QrcodeOutlined />}
loading={binding}
onClick={bindAccount}
>
生成绑定二维码
</Button>
<Button
icon={<SendOutlined />}
loading={submitting}
disabled={!bindResult || !gameAccount}
onClick={submitDelivery}
>
我已扫码,提交发货
</Button>
</Space>
{bindResult && (
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'center' }}>
<Image
src={bindResult.qr_url}
width={220}
height={220}
alt="绑定二维码"
style={{ background: '#fff', border: '1px solid #e5e7eb' }}
/>
<Space direction="vertical" size="small" style={{ flex: 1, minWidth: 260 }}>
<Typography.Text strong>绑定凭证</Typography.Text>
<Typography.Text code copyable={{ text: bindResult.bind_uuid }}>
{bindResult.bind_uuid}
</Typography.Text>
<Typography.Link href={bindResult.bind_url} target="_blank" rel="noreferrer">
打开绑定链接
</Typography.Link>
</Space>
</div>
)}
{submitResult && (
<Alert
type="success"
showIcon
message={submitResult.message}
description={submitResult.provider_order_no ? `上游单号:${submitResult.provider_order_no}` : undefined}
/>
)}
</Space>
</section>
)}
</Space>
</div>
</main>
)
}
function CenteredShell({ children }: { children: ReactNode }) {
return (
<main style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: '#f5f7fb', padding: 24 }}>
{children}
</main>
)
}
const panelStyle: CSSProperties = {
background: '#fff',
border: '1px solid #e5e7eb',
borderRadius: 8,
padding: 24,
}