接入无感发货流程

This commit is contained in:
yml2213
2026-07-31 10:45:13 +08:00
parent f29f3eb841
commit 6ca226ce4b
12 changed files with 951 additions and 23 deletions
+3
View File
@@ -6,3 +6,6 @@
# 后端 API 代理目标(见 vite.config.ts
VITE_API_PROXY_TARGET=http://localhost:8080
# 用户侧发货页面基础地址;为空时按当前前端域名生成
VITE_DELIVERY_BASE_URL=
+2
View File
@@ -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={
+17
View File
@@ -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>),
+259
View File
@@ -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,
}
+34 -13
View File
@@ -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>
+40
View File
@@ -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