对接皮肤源头开放接口:查询、发货推送与 HMAC 签名鉴权

- 新增订单查询与发货结果推送开放接口,支持 can_ship 与幂等
- 鉴权采用 X-Api-Key + Timestamp + Nonce + HMAC-SHA256 签名
- 订单扩展发货字段与 ship_logs,管理端增加发货记录与开放文档页
This commit is contained in:
yml2213
2026-07-20 16:06:44 +08:00
parent 829bea309d
commit 89cdd32181
16 changed files with 1584 additions and 39 deletions
+245
View File
@@ -0,0 +1,245 @@
import { useCallback, useEffect, useState } from 'react'
import {
Button,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { shipLogApi } from '../api'
import type { ShipLog } from '../types'
const shipStatusMap: Record<string, { color: string; text: string }> = {
success: { color: 'green', text: '成功' },
failed: { color: 'red', text: '失败' },
processing: { color: 'cyan', text: '发货中' },
}
const orderStatusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'orange', text: '待支付' },
paid: { color: 'blue', text: '已支付' },
delivering: { color: 'cyan', text: '发货中' },
delivered: { color: 'green', text: '已交付' },
ship_failed: { color: 'red', text: '发货失败' },
cancelled: { color: 'default', text: '已取消' },
}
export default function ShipLogs() {
const [list, setList] = useState<ShipLog[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [orderNo, setOrderNo] = useState('')
const [shipStatus, setShipStatus] = useState<string | undefined>()
const [loading, setLoading] = useState(false)
const [detail, setDetail] = useState<ShipLog | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const data = await shipLogApi.list({
page,
size,
order_no: orderNo || undefined,
ship_status: shipStatus,
})
setList(data.list || [])
setTotal(data.total)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [page, size, orderNo, shipStatus])
useEffect(() => {
load()
}, [load])
const columns: ColumnsType<ShipLog> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '店铺订单号', dataIndex: 'order_no', width: 200, ellipsis: true },
{
title: '推送状态',
dataIndex: 'ship_status',
width: 100,
render: (v: string) => {
const s = shipStatusMap[v] || { color: 'default', text: v }
return <Tag color={s.color}>{s.text}</Tag>
},
},
{
title: '处理后订单状态',
dataIndex: 'result_status',
width: 130,
render: (v: string) => {
if (!v) return '-'
const s = orderStatusMap[v] || { color: 'default', text: v }
return <Tag color={s.color}>{s.text}</Tag>
},
},
{
title: '上游单号',
dataIndex: 'provider_order_no',
width: 160,
ellipsis: true,
render: (v: string) => v || '-',
},
{
title: '说明',
dataIndex: 'message',
ellipsis: true,
render: (v: string) => v || '-',
},
{
title: '失败原因',
dataIndex: 'fail_reason',
width: 140,
ellipsis: true,
render: (v: string) => v || '-',
},
{
title: '时间',
dataIndex: 'created_at',
width: 170,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
key: 'action',
width: 90,
render: (_, record) => (
<Button type="link" size="small" onClick={() => setDetail(record)}>
</Button>
),
},
]
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Space>
<Input.Search
placeholder="店铺订单号"
allowClear
onSearch={(v) => {
setPage(1)
setOrderNo(v)
}}
style={{ width: 220 }}
/>
<Select
allowClear
placeholder="推送状态"
style={{ width: 140 }}
value={shipStatus}
onChange={(v) => {
setPage(1)
setShipStatus(v)
}}
options={[
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'processing', label: '发货中' },
]}
/>
<Button icon={<ReloadOutlined />} onClick={load}>
</Button>
</Space>
</Space>
<Typography.Paragraph type="secondary" style={{ marginTop: -4 }}>
便
</Typography.Paragraph>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={list}
tableLayout="fixed"
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, s) => {
setPage(p)
setSize(s)
},
}}
/>
<Modal
title="推送详情"
open={!!detail}
onCancel={() => setDetail(null)}
footer={null}
width={640}
>
{detail && (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text copyable>{detail.order_no}</Typography.Text>
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
{detail.provider_order_no || '-'}
</div>
<div>
<Typography.Text type="secondary"> / </Typography.Text>
{detail.ship_status} {detail.result_status || '-'}
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
{detail.message || '-'}
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
{detail.fail_reason || '-'}
</div>
<div>
<Typography.Text type="secondary"></Typography.Text>
<pre
style={{
marginTop: 8,
padding: 12,
background: '#f5f5f5',
borderRadius: 6,
maxHeight: 280,
overflow: 'auto',
fontSize: 12,
}}
>
{formatPayload(detail.payload)}
</pre>
</div>
</Space>
)}
</Modal>
</div>
)
}
function formatPayload(raw: string) {
if (!raw) return '-'
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}