接入无感发货流程
This commit is contained in:
@@ -7,6 +7,7 @@ import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import OpenApiDocs from './pages/OpenApiDocs'
|
||||
import ApiDebugger from './pages/ApiDebugger'
|
||||
import Delivery from './pages/Delivery'
|
||||
import MerchantCenter from './pages/MerchantCenter'
|
||||
import PlatformMerchants from './pages/PlatformMerchants'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -26,6 +27,7 @@ function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/delivery/:channel/:orderNo" element={<Delivery />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
|
||||
@@ -6,6 +6,9 @@ import type {
|
||||
CallbackCredential,
|
||||
CallbackSubscription,
|
||||
CreateTestOrderResult,
|
||||
DeliveryBindResult,
|
||||
DeliveryOrderInfo,
|
||||
DeliverySubmitResult,
|
||||
FulfillmentOrder,
|
||||
LoginResult,
|
||||
Merchant,
|
||||
@@ -93,6 +96,20 @@ export const merchantApi = {
|
||||
request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember),
|
||||
}
|
||||
|
||||
export const deliveryApi = {
|
||||
getOrder: (orderNo: string) =>
|
||||
request.get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}`).then((r) => r.data.data as DeliveryOrderInfo),
|
||||
bind: (orderNo: string, gameAccount: string) =>
|
||||
request.post(`/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind`, {
|
||||
game_account: gameAccount,
|
||||
}).then((r) => r.data.data as DeliveryBindResult),
|
||||
submit: (orderNo: string, gameAccount: string, bindUUID: string) =>
|
||||
request.post(`/delivery/v1/orders/${encodeURIComponent(orderNo)}/submit`, {
|
||||
game_account: gameAccount,
|
||||
bind_uuid: bindUUID,
|
||||
}).then((r) => r.data.data as DeliverySubmitResult),
|
||||
}
|
||||
|
||||
export const platformApi = {
|
||||
merchants: (params?: Record<string, unknown>) =>
|
||||
request.get('/platform/merchants', { params }).then((r) => r.data.data as PageResult<Merchant>),
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useParams } 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 dayjs from 'dayjs'
|
||||
import { deliveryApi } from '../api'
|
||||
import type { DeliveryBindResult, DeliveryOrderInfo, DeliverySubmitResult } from '../types'
|
||||
|
||||
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 [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 gameAccount = Form.useWatch('game_account', form)
|
||||
|
||||
useEffect(() => {
|
||||
if (!decodedOrderNo) {
|
||||
setError('发货链接缺少订单号')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
deliveryApi.getOrder(decodedOrderNo)
|
||||
.then((data) => {
|
||||
setOrder(data)
|
||||
setError('')
|
||||
})
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '订单加载失败'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [decodedOrderNo])
|
||||
|
||||
const bindAccount = async () => {
|
||||
const values = await form.validateFields()
|
||||
setBinding(true)
|
||||
setSubmitResult(null)
|
||||
try {
|
||||
const result = await deliveryApi.bind(decodedOrderNo, values.game_account)
|
||||
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)
|
||||
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="创建时间">{formatTime(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>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
|
||||
}
|
||||
|
||||
const panelStyle: CSSProperties = {
|
||||
background: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 8,
|
||||
padding: 24,
|
||||
}
|
||||
@@ -76,7 +76,8 @@ const eventOptions = [
|
||||
{ value: 'order.cancelled', label: '订单取消' },
|
||||
]
|
||||
|
||||
const deliveryTestUrl = import.meta.env.VITE_DELIVERY_TEST_URL || 'https://www.jxya.top/test-delivery/dlc/'
|
||||
const deliveryBaseUrl = (import.meta.env.VITE_DELIVERY_BASE_URL || window.location.origin).replace(/\/+$/, '')
|
||||
const deliveryChannel = 'dlc'
|
||||
|
||||
const testOrderStatusOptions = [
|
||||
{ value: 'pending', label: '已支付,可发货' },
|
||||
@@ -123,6 +124,14 @@ export default function MerchantCenter() {
|
||||
label: productOptionLabel(item),
|
||||
})), [testOrderProducts])
|
||||
|
||||
const copyDeliveryLink = useCallback((orderNo: string) => {
|
||||
navigator.clipboard.writeText(buildDeliveryLink(orderNo)).then(() => message.success('发货链接已复制'))
|
||||
}, [])
|
||||
|
||||
const openDeliveryLink = useCallback((orderNo: string) => {
|
||||
window.open(buildDeliveryLink(orderNo), '_blank', 'noopener,noreferrer')
|
||||
}, [])
|
||||
|
||||
const loadCurrent = useCallback(async () => {
|
||||
const data = await merchantApi.current()
|
||||
setMerchant(data.merchant)
|
||||
@@ -427,6 +436,17 @@ export default function MerchantCenter() {
|
||||
{ title: '履约', dataIndex: 'fulfillment_status', width: 100, render: fulfillmentStatusTag },
|
||||
{ title: '上游单号', dataIndex: 'provider_order_no', width: 140, ellipsis: true, render: (v) => v || '-' },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 160, render: formatTime },
|
||||
{
|
||||
title: '发货链接',
|
||||
key: 'delivery_link',
|
||||
width: 150,
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" onClick={() => copyDeliveryLink(record.order_no)}>复制</Button>
|
||||
<Button type="link" size="small" onClick={() => openDeliveryLink(record.order_no)}>打开</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const ledgerColumns: ColumnsType<WalletLedgerEntry> = [
|
||||
@@ -532,12 +552,9 @@ export default function MerchantCenter() {
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Text type="secondary">订单号用于发货平台查询。</Typography.Text>
|
||||
<Typography.Text type="secondary">发货链接会自动携带订单号,用户只填写游戏 UID。</Typography.Text>
|
||||
{canManage && (
|
||||
<Space>
|
||||
<Button icon={<LinkOutlined />} href={deliveryTestUrl} target="_blank" rel="noreferrer">
|
||||
发货测试页
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openTestOrderCreate}>
|
||||
创建测试订单
|
||||
</Button>
|
||||
@@ -550,7 +567,7 @@ export default function MerchantCenter() {
|
||||
columns={orderColumns}
|
||||
dataSource={orders.list}
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1300 }}
|
||||
scroll={{ x: 1450 }}
|
||||
pagination={pageConfig(orders, loadOrders)}
|
||||
/>
|
||||
</Space>
|
||||
@@ -733,12 +750,12 @@ export default function MerchantCenter() {
|
||||
<Button onClick={() => setTestOrderResult(null)}>关闭</Button>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => navigator.clipboard.writeText(testOrderResult?.order.order_no || '').then(() => message.success('已复制'))}
|
||||
onClick={() => copyDeliveryLink(testOrderResult?.order.order_no || '')}
|
||||
>
|
||||
复制订单号
|
||||
复制发货链接
|
||||
</Button>
|
||||
<Button type="primary" icon={<LinkOutlined />} href={deliveryTestUrl} target="_blank" rel="noreferrer">
|
||||
发货测试页
|
||||
<Button type="primary" icon={<LinkOutlined />} onClick={() => openDeliveryLink(testOrderResult?.order.order_no || '')}>
|
||||
打开发货页
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
@@ -756,9 +773,9 @@ export default function MerchantCenter() {
|
||||
{testOrderResult.can_ship ? 'can_ship=true' : 'can_ship=false'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="测试页">
|
||||
<Typography.Link href={deliveryTestUrl} target="_blank" rel="noreferrer">
|
||||
{deliveryTestUrl}
|
||||
<Descriptions.Item label="发货链接">
|
||||
<Typography.Link href={buildDeliveryLink(testOrderResult.order.order_no)} target="_blank" rel="noreferrer">
|
||||
{buildDeliveryLink(testOrderResult.order.order_no)}
|
||||
</Typography.Link>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
@@ -918,6 +935,10 @@ function productOptionLabel(item: MerchantProduct) {
|
||||
return name === item.sku ? item.sku : `${name} / ${item.sku}`
|
||||
}
|
||||
|
||||
function buildDeliveryLink(orderNo: string) {
|
||||
return `${deliveryBaseUrl}/delivery/${deliveryChannel}/${encodeURIComponent(orderNo)}`
|
||||
}
|
||||
|
||||
function paymentStatusTag(value: string) {
|
||||
const item = paymentStatusMap[value] || { color: 'default', text: value }
|
||||
return <Tag color={item.color}>{item.text}</Tag>
|
||||
|
||||
@@ -124,6 +124,46 @@ export interface CreateTestOrderResult {
|
||||
cannot_ship_reason?: string
|
||||
}
|
||||
|
||||
export interface DeliveryProduct {
|
||||
name: string
|
||||
sku: string
|
||||
game: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
export interface DeliveryOrderInfo {
|
||||
order_no: string
|
||||
status: string
|
||||
can_ship: boolean
|
||||
cannot_ship_reason?: string
|
||||
product?: DeliveryProduct
|
||||
buyer_name: string
|
||||
amount: number
|
||||
created_at: string
|
||||
shipped_at?: string | null
|
||||
ship_fail_reason?: string
|
||||
game_channel?: string
|
||||
game_uid?: string
|
||||
role_name?: string
|
||||
pay_score?: number
|
||||
good?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface DeliveryBindResult {
|
||||
bind_uuid: string
|
||||
bind_url: string
|
||||
qr_url: string
|
||||
}
|
||||
|
||||
export interface DeliverySubmitResult {
|
||||
order_no: string
|
||||
status: string
|
||||
message: string
|
||||
provider_order_no?: string
|
||||
game_account?: Record<string, unknown>
|
||||
upstream_order?: unknown
|
||||
}
|
||||
|
||||
export interface ApiClient {
|
||||
id: number
|
||||
merchant_id: number
|
||||
|
||||
Reference in New Issue
Block a user