Files
dukang/apps/admin-web/src/pages/OrdersPage.tsx
T
jacy f6d97b4ee8
CI / verify (pull_request) Has been cancelled
feat(trade): 仓配可调配履约与三分支推单
同城有仓按仓库绑定承运商自动推单或自管填单,无仓/跨城走总部快递;新增仓配注册表、FulfillmentService 及三端运单追踪。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 17:51:20 +08:00

521 lines
20 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, useMemo, useState } from 'react';
import {
Alert,
Button,
Collapse,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
type ShipDefaults = {
provider: string;
providerLabel: string;
fromName: string;
fromMobile: string;
fromAddress: string;
fromAddressDetail: string;
fromLng: number;
fromLat: number;
weight: number;
payMode: string;
};
type OrderDetail = AdminOrderRow & {
receiverAddress?: string;
receiverProvince?: string;
receiverCity?: string;
receiverDistrict?: string;
clientIp?: string | null;
ipProvince?: string | null;
ipCity?: string | null;
ipDistrict?: string | null;
gpsProvince?: string | null;
gpsCity?: string | null;
gpsDistrict?: string | null;
gpsLatitude?: number | null;
gpsLongitude?: number | null;
gpsAddress?: string | null;
productAmount?: number;
freightAmount?: number;
benefitAmount?: number;
deliveryType?: string;
fulfillmentWarehouseId?: string | null;
paidAt?: string | null;
payExpireAt?: string | null;
items?: Array<Record<string, unknown>>;
payment?: Record<string, unknown> | null;
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
benefitCoupons?: Array<Record<string, unknown>>;
city?: { name: string; code: string };
};
export default function OrdersPage() {
const [form] = Form.useForm();
const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm();
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [detail, setDetail] = useState<OrderDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
const [batchDeleting, setBatchDeleting] = useState(false);
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
const [shipping, setShipping] = useState(false);
const [logisticsShipping, setLogisticsShipping] = useState(false);
const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
[data?.items, selectedRowKeys],
);
const load = useCallback(async () => {
setLoading(true);
try {
const values = form.getFieldsValue();
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (values.orderNo) qs.set('orderNo', values.orderNo);
if (values.status) qs.set('status', values.status);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res);
} finally {
setLoading(false);
}
}, [form, page, pageSize]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
void request<ShipDefaults>('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {});
}, []);
async function openDetail(id: string) {
const res = await request<OrderDetail>(`/admin/orders/${id}`);
setDetail(res);
setDrawerOpen(true);
const defaults = shipDefaults ?? await request<ShipDefaults>('/admin/orders/ship-defaults').catch(() => null);
if (defaults) {
setShipDefaults(defaults);
shipForm.setFieldsValue({
provider: defaults.provider,
weight: defaults.weight,
payMode: defaults.payMode,
fromName: defaults.fromName,
fromMobile: defaults.fromMobile,
fromAddress: defaults.fromAddress,
fromAddressDetail: defaults.fromAddressDetail,
fromLng: defaults.fromLng,
fromLat: defaults.fromLat,
});
}
}
async function submitShip() {
if (!detail) return;
const values = await shipForm.validateFields();
setShipping(true);
try {
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/ship`, {
method: 'POST',
body: JSON.stringify(values),
});
message.success('发货成功');
setDetail(res);
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '发货失败');
} finally {
setShipping(false);
}
}
async function submitLogisticsShip() {
if (!detail) return;
const values = await logisticsForm.validateFields();
setLogisticsShipping(true);
try {
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/logistics-ship`, {
method: 'POST',
body: JSON.stringify(values),
});
message.success('快递单已录入');
setDetail(res);
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '填单失败');
} finally {
setLogisticsShipping(false);
}
}
async function confirmBatchDelete() {
if (!selectedRowKeys.length) return;
setBatchDeleting(true);
try {
const res = await request<{ deleted: number; message: string }>('/admin/orders/batch-delete', {
method: 'POST',
body: JSON.stringify({ ids: selectedRowKeys }),
});
message.success(res.message || `已删除 ${res.deleted} 笔订单`);
setBatchDeleteOpen(false);
setSelectedRowKeys([]);
if (detail && selectedRowKeys.includes(detail.id)) {
setDrawerOpen(false);
setDetail(null);
}
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '批量删除失败');
} finally {
setBatchDeleting(false);
}
}
const columns: ColumnsType<AdminOrderRow> = [
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s) => (
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
),
},
{
title: '配送',
dataIndex: 'deliveryType',
width: 90,
render: (v) => (v === 'LOCAL' ? '同城' : '跨城'),
},
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => ${v}` },
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
{ title: '手机', dataIndex: 'receiverPhone', width: 120 },
{
title: '用户',
dataIndex: ['user', 'userNo'],
width: 110,
render: (_, row) => row.user?.userNo || '—',
},
{
title: '快递',
width: 100,
render: (_, row) => row.delivery?.trackingNo || row.delivery?.provider || '—',
},
{
title: '下单时间',
dataIndex: 'createdAt',
width: 170,
render: (v) => new Date(v).toLocaleString('zh-CN'),
},
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
详情
</Button>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="orderNo" label="订单号">
<Input placeholder="DK..." allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="receiverPhone" label="收货手机">
<Input allowClear />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">查询</Button>
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="订单详情"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={detail && (
<Space>
<Typography.Text type="secondary">调试改状态</Typography.Text>
<Select
value={detail.status}
style={{ width: 120 }}
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => {
await request(`/admin/orders/${detail.id}/status`, {
method: 'PUT',
body: JSON.stringify({ status }),
});
message.success('状态已更新(调试)');
const res = await request<OrderDetail>(`/admin/orders/${detail.id}`);
setDetail(res);
void load();
}}
/>
</Space>
)}
>
{detail && (
<>
<Descriptions column={1} bordered size="small" title="基本信息">
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
{ORDER_STATUS_LABELS[detail.status] || detail.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
<Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item>
<Descriptions.Item label="下单时间">{new Date(detail.createdAt).toLocaleString('zh-CN')}</Descriptions.Item>
</Descriptions>
<Descriptions column={1} bordered size="small" title="收货信息" style={{ marginTop: 16 }}>
<Descriptions.Item label="收货人">{detail.receiverName} {detail.receiverPhone}</Descriptions.Item>
<Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item>
</Descriptions>
<Descriptions column={1} bordered size="small" title="位置快照(方案C" style={{ marginTop: 16 }}>
<Descriptions.Item label="clientIp">{detail.clientIp || '—'}</Descriptions.Item>
<Descriptions.Item label="IP解析">{[detail.ipProvince, detail.ipCity, detail.ipDistrict].filter(Boolean).join(' / ') || '—'}</Descriptions.Item>
<Descriptions.Item label="GPS">{[detail.gpsProvince, detail.gpsCity, detail.gpsDistrict].filter(Boolean).join(' / ') || '—'}</Descriptions.Item>
<Descriptions.Item label="坐标">
{detail.gpsLatitude != null ? `${detail.gpsLatitude}, ${detail.gpsLongitude}` : '—'}
</Descriptions.Item>
</Descriptions>
{detail.delivery && (
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
<Descriptions.Item label="快递公司">
{detail.delivery.logisticsCompany ||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
detail.delivery.provider}
</Descriptions.Item>
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
{detail.delivery.manualQueryUrl && (
<Descriptions.Item label="查询链接">
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
打开物流查询
</a>
</Descriptions.Item>
)}
</Descriptions>
)}
{['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && (
<div style={{ marginTop: 16 }}>
{(detail.deliveryType === 'CROSS_CITY' || !detail.fulfillmentWarehouseId) && (
<>
<Typography.Title level={5}>总部快递填单</Typography.Title>
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
适用于跨城订单或同城无仓订单
</Typography.Paragraph>
<Form form={logisticsForm} layout="vertical" size="small">
<Form.Item name="logisticsCompany" label="快递公司" rules={[{ required: true }]}>
<Input placeholder="如 顺丰速运、京东物流" />
</Form.Item>
<Form.Item name="trackingNo" label="运单号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="manualQueryUrl" label="物流查询链接(可选)">
<Input placeholder="https://..." />
</Form.Item>
<Button type="primary" loading={logisticsShipping} onClick={() => void submitLogisticsShip()}>
提交快递单
</Button>
</Form>
</>
)}
{detail.fulfillmentWarehouseId && (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
仓配小飞侠重试
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
仓配订单通常支付后自动推单;失败时可手动重试
</Typography.Paragraph>
<Form form={shipForm} layout="vertical" size="small">
<Form.Item name="provider" label="快递公司" rules={[{ required: true }]}>
<Select
options={[{ value: 'XFX', label: '小飞侠' }]}
/>
</Form.Item>
<Form.Item name="weight" label="重量(kg)" rules={[{ required: true }]}>
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="payMode" label="付费方式" rules={[{ required: true }]}>
<Select
options={[
{ value: '1', label: '寄付' },
{ value: '2', label: '到付' },
]}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="可选" />
</Form.Item>
<Collapse
ghost
items={[{
key: 'from',
label: '寄件信息(默认仓库,可修改)',
children: (
<>
<Form.Item name="fromName" label="寄件人" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromMobile" label="寄件手机" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromAddress" label="寄件区域" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromAddressDetail" label="寄件详细地址" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Space>
<Form.Item name="fromLng" label="经度">
<InputNumber step={0.001} />
</Form.Item>
<Form.Item name="fromLat" label="纬度">
<InputNumber step={0.001} />
</Form.Item>
</Space>
</>
),
}]}
/>
<Button type="primary" loading={shipping} onClick={() => void submitShip()}>
调用小飞侠发货
</Button>
</Form>
</>
)}
</div>
)}
{detail.statusLogs && detail.statusLogs.length > 0 && (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>状态流转</Typography.Title>
<Table
size="small"
rowKey="createdAt"
pagination={false}
dataSource={detail.statusLogs}
columns={[
{ title: '从', dataIndex: 'fromStatus', render: (v) => v || '—' },
{ title: '到', dataIndex: 'toStatus', render: (v) => ORDER_STATUS_LABELS[v] || v },
{ title: '时间', dataIndex: 'createdAt', render: (v) => new Date(v).toLocaleString('zh-CN') },
]}
/>
</>
)}
</>
)}
</Drawer>
<Modal
title={`确认批量删除(${selectedOrders.length} 笔)`}
open={batchDeleteOpen}
okText="确认删除"
okButtonProps={{ danger: true, loading: batchDeleting }}
onOk={() => void confirmBatchDelete()}
onCancel={() => setBatchDeleteOpen(false)}
width={720}
destroyOnClose
>
<Alert
type="error"
showIcon
style={{ marginBottom: 16 }}
message="此操作不可恢复"
description="将删除所选订单及其配送单、权益券、核销记录等关联业务数据。订单状态流转等业务日志将保留。"
/>
<Table
size="small"
rowKey="id"
pagination={false}
scroll={{ x: 600, y: 280 }}
dataSource={selectedOrders}
columns={[
{ title: '订单号', dataIndex: 'orderNo', width: 170 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s) => (
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
),
},
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => ${v}` },
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
]}
/>
</Modal>
</div>
);
}