Files
affiliate_dash/frontend/src/pages/Orders.tsx
T
yml2213 e4d0a71963 支持 .env 配置,并完善源头对接文档与测试订单
- 增加 .env.example 与 godotenv 加载,start.sh 自动读环境变量
- 重写发给源头的开放接口对接文档
- 订单页支持创建测试订单(可直接已支付并复制店铺订单号)
2026-07-20 16:19:57 +08:00

403 lines
12 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 { useCallback, useEffect, useState } from 'react'
import {
Button,
Checkbox,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd'
import { PlusOutlined, ReloadOutlined, CopyOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import { orderApi, skinApi } from '../api'
import { useAuth } from '../store/auth'
import type { Order, Skin } from '../types'
const statusMap: 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 Orders() {
const { isAdmin } = useAuth()
const [list, setList] = useState<Order[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [status, setStatus] = useState<string | undefined>()
const [loading, setLoading] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [skins, setSkins] = useState<Skin[]>([])
const [creating, setCreating] = useState(false)
const [createdOrder, setCreatedOrder] = useState<Order | null>(null)
const [form] = Form.useForm()
const load = useCallback(async () => {
setLoading(true)
try {
const data = await orderApi.list({ page, size, status })
setList(data.list || [])
setTotal(data.total)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [page, size, status])
useEffect(() => {
load()
}, [load])
const openCreate = async () => {
form.resetFields()
form.setFieldsValue({
buyer_name: '测试买家',
mark_paid: true,
remark: '联调测试订单',
})
setCreatedOrder(null)
setCreateOpen(true)
try {
const data = await skinApi.list({ page: 1, size: 100, status: 1 })
setSkins(data.list || [])
} catch (e) {
message.error(e instanceof Error ? e.message : '加载商品失败')
}
}
const onCreate = async () => {
const values = await form.validateFields()
setCreating(true)
try {
const order = await orderApi.create({
skin_id: values.skin_id,
buyer_name: values.buyer_name,
remark: values.remark,
status: isAdmin && values.mark_paid ? 'paid' : undefined,
})
setCreatedOrder(order)
message.success(`测试订单已创建:${order.order_no}`)
load()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
setCreating(false)
}
}
const copyText = async (text: string, tip = '已复制') => {
try {
await navigator.clipboard.writeText(text)
message.success(tip)
} catch {
message.error('复制失败,请手动选择')
}
}
const changeStatus = async (id: number, next: string) => {
try {
await orderApi.updateStatus(id, next)
message.success('状态已更新')
load()
} catch (e) {
message.error(e instanceof Error ? e.message : '更新失败')
}
}
const columns: ColumnsType<Order> = [
{
title: '店铺订单号',
dataIndex: 'order_no',
width: 210,
ellipsis: true,
render: (v: string) => (
<Space size={4}>
<Typography.Text copyable={{ text: v }} style={{ maxWidth: 160 }} ellipsis>
{v}
</Typography.Text>
</Space>
),
},
{
title: '皮肤',
dataIndex: ['skin', 'name'],
width: 140,
ellipsis: true,
render: (_, r) => r.skin?.name || `#${r.skin_id}`,
},
{
title: 'SKU',
width: 150,
ellipsis: true,
render: (_, r) =>
r.skin?.sku ? (
<Typography.Text code copyable={{ text: r.skin.sku }}>
{r.skin.sku}
</Typography.Text>
) : (
'-'
),
},
{
title: '分销商',
dataIndex: ['distributor', 'nickname'],
width: 100,
ellipsis: true,
render: (_, r) => r.distributor?.nickname || r.distributor?.username || `#${r.distributor_id}`,
},
{ title: '买家', dataIndex: 'buyer_name', width: 90, ellipsis: true },
{
title: '金额',
dataIndex: 'amount',
width: 90,
render: (v: number) => `¥${Number(v ?? 0).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => {
const s = statusMap[v] || { color: 'default', text: v }
return <Tag color={s.color}>{s.text}</Tag>
},
},
{
title: '上游单号',
dataIndex: 'provider_order_no',
width: 120,
ellipsis: true,
render: (v?: string) => v || '-',
},
{
title: '时间',
dataIndex: 'created_at',
width: 160,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
},
]
if (isAdmin) {
columns.push({
title: '操作',
key: 'action',
width: 200,
fixed: 'right',
render: (_, record) => (
<Space size={0}>
{record.status === 'pending' && (
<>
<Button type="link" size="small" onClick={() => changeStatus(record.id, 'paid')}>
标记已付
</Button>
<Button type="link" size="small" danger onClick={() => changeStatus(record.id, 'cancelled')}>
取消
</Button>
</>
)}
{(record.status === 'paid' ||
record.status === 'ship_failed' ||
record.status === 'delivering') && (
<Button type="link" size="small" onClick={() => changeStatus(record.id, 'delivered')}>
标记交付
</Button>
)}
</Space>
),
})
}
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
订单管理
</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
店铺订单号 order_no 给源头查询发货;已支付订单 can_ship=true
</Typography.Text>
</div>
<Space>
<Select
allowClear
placeholder="订单状态"
style={{ width: 140 }}
value={status}
onChange={(v) => {
setPage(1)
setStatus(v)
}}
options={[
{ value: 'pending', label: '待支付' },
{ value: 'paid', label: '已支付' },
{ value: 'delivering', label: '发货中' },
{ value: 'delivered', label: '已交付' },
{ value: 'ship_failed', label: '发货失败' },
{ value: 'cancelled', label: '已取消' },
]}
/>
<Button icon={<ReloadOutlined />} onClick={load}>
刷新
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
创建测试订单
</Button>
</Space>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={list}
tableLayout="fixed"
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize: size,
total,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条`,
onChange: (p, s) => {
setPage(p)
setSize(s)
},
}}
/>
<Modal
title="创建测试订单(联调用)"
open={createOpen}
onCancel={() => setCreateOpen(false)}
footer={
createdOrder
? [
<Button key="close" onClick={() => setCreateOpen(false)}>
关闭
</Button>,
<Button
key="again"
type="primary"
onClick={() => {
setCreatedOrder(null)
form.setFieldsValue({ mark_paid: true })
}}
>
再下一单
</Button>,
]
: [
<Button key="cancel" onClick={() => setCreateOpen(false)}>
取消
</Button>,
<Button key="ok" type="primary" loading={creating} onClick={onCreate}>
创建
</Button>,
]
}
destroyOnClose
width={560}
>
{createdOrder ? (
<div>
<Typography.Paragraph>
订单已创建。把下面的 <Typography.Text strong>店铺订单号</Typography.Text>{' '}
发给源头,或用开放接口查询:
</Typography.Paragraph>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div
style={{
padding: 16,
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 8,
}}
>
<div style={{ marginBottom: 8, color: '#666' }}>店铺订单号 order_no</div>
<Space>
<Typography.Title level={4} style={{ margin: 0 }} copyable>
{createdOrder.order_no}
</Typography.Title>
<Button
icon={<CopyOutlined />}
onClick={() => copyText(createdOrder.order_no, '订单号已复制')}
>
复制
</Button>
</Space>
</div>
<div>
<div>商品:{createdOrder.skin?.name || `#${createdOrder.skin_id}`}</div>
<div>
SKU:
<Typography.Text code copyable>
{createdOrder.skin?.sku || '-'}
</Typography.Text>
</div>
<div>
状态:
<Tag color={statusMap[createdOrder.status]?.color}>
{statusMap[createdOrder.status]?.text || createdOrder.status}
</Tag>
{createdOrder.status === 'paid' && (
<Typography.Text type="success">(可发货 can_ship=true)</Typography.Text>
)}
</div>
<div>买家:{createdOrder.buyer_name}</div>
</div>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 12 }}>
源头查询:GET /api/open/v1/orders/{createdOrder.order_no}
<br />
需带 X-Api-Key / X-Timestamp / X-Nonce / X-Sign
</Typography.Paragraph>
</Space>
</div>
) : (
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
<Form.Item
name="skin_id"
label="商品皮肤"
rules={[{ required: true, message: '请选择商品' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="选择要发货的皮肤"
options={skins.map((s) => ({
value: s.id,
label: `${s.name}(${s.sku})`,
}))}
/>
</Form.Item>
<Form.Item name="buyer_name" label="买家名称">
<Input placeholder="测试买家" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="联调说明" />
</Form.Item>
{isAdmin && (
<Form.Item name="mark_paid" valuePropName="checked">
<Checkbox>直接标记为已支付(源头可立即查询并发货)</Checkbox>
</Form.Item>
)}
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 0 }}>
创建后系统会生成店铺订单号(如 O20260720…),这就是源头「输入订单号」要用的号。
</Typography.Paragraph>
</Form>
)}
</Modal>
</div>
)
}