cc0c0a6ef8
同城 MANUAL/ZZXFX 按小飞侠价规计费,路由查询回退仓配凭证;HQ 配送单补商品、用户和收货地址。门店核销回跳与小程序核销码一并带上。 Co-authored-by: Cursor <cursoragent@cursor.com>
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
import { useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import {
|
||
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||
import { request } from '../lib/api';
|
||
import {
|
||
DELIVERY_PROVIDER_LABELS,
|
||
DELIVERY_TYPE_LABELS,
|
||
ORDER_STATUS_LABELS,
|
||
fmtTime,
|
||
} from '../lib/constants';
|
||
import { useAdminList } from '../lib/useAdminList';
|
||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||
import { AdminListHeader } from '../components/AdminListHeader';
|
||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||
|
||
type DeliveryOrder = {
|
||
id: string;
|
||
orderNo: string;
|
||
status: string;
|
||
deliveryType: string;
|
||
productName: string;
|
||
productSpec?: string | null;
|
||
barcode69?: string | null;
|
||
quantity: number;
|
||
saleUnit?: string;
|
||
bottlesPerUnit?: number;
|
||
payAmount?: number;
|
||
receiverName: string;
|
||
receiverPhone: string;
|
||
receiverAddress?: string;
|
||
receiverProvince?: string;
|
||
receiverCity?: string;
|
||
receiverDistrict?: string;
|
||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||
imageResource?: { url: string } | null;
|
||
};
|
||
|
||
type Row = {
|
||
id: string;
|
||
orderId: string;
|
||
provider: string;
|
||
trackingNo: string | null;
|
||
providerOrderNo: string | null;
|
||
updatedAt: string;
|
||
logisticsFee?: number | null;
|
||
order?: DeliveryOrder;
|
||
};
|
||
|
||
function formatQty(order?: DeliveryOrder | null) {
|
||
if (!order || order.quantity == null) return '—';
|
||
const unit = order.saleUnit === 'BOX' ? '箱' : '瓶';
|
||
const bottles =
|
||
order.saleUnit === 'BOX' && order.bottlesPerUnit && order.bottlesPerUnit > 1
|
||
? `(${order.quantity * order.bottlesPerUnit}瓶)`
|
||
: '';
|
||
return `${order.quantity}${unit}${bottles}`;
|
||
}
|
||
|
||
function formatAddress(order?: DeliveryOrder | null) {
|
||
if (!order) return '—';
|
||
if (order.deliveryType === 'ON_SITE_PICKUP') return '现场取货';
|
||
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
||
.filter(Boolean)
|
||
.join('');
|
||
const detail = (order.receiverAddress || '').trim();
|
||
if (!region) return detail || '—';
|
||
if (!detail || detail.startsWith(region)) return detail || region;
|
||
return `${region}${detail}`;
|
||
}
|
||
|
||
export default function DeliveriesPage() {
|
||
const navigate = useNavigate();
|
||
const [form] = Form.useForm();
|
||
const [editForm] = Form.useForm();
|
||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||
'/admin/deliveries',
|
||
() => {
|
||
const qs = new URLSearchParams();
|
||
if (filters.provider) qs.set('provider', filters.provider);
|
||
if (filters.trackingNo) qs.set('trackingNo', filters.trackingNo);
|
||
if (filters.orderNo) qs.set('orderNo', filters.orderNo);
|
||
return qs;
|
||
},
|
||
[filters],
|
||
);
|
||
const [detail, setDetail] = useState<Row | null>(null);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [trackOpen, setTrackOpen] = useState(false);
|
||
const [trackOrderId, setTrackOrderId] = useState<string | null>(null);
|
||
const [trackOrderNo, setTrackOrderNo] = useState<string | null>(null);
|
||
const [trackOrderStatus, setTrackOrderStatus] = useState<string | null>(null);
|
||
|
||
function openTrack(row: Row) {
|
||
const orderId = row.orderId || row.order?.id;
|
||
if (!orderId) {
|
||
message.warning('缺少关联订单,无法查询路由');
|
||
return;
|
||
}
|
||
setTrackOrderId(orderId);
|
||
setTrackOrderNo(row.order?.orderNo ?? null);
|
||
setTrackOrderStatus(row.order?.status ?? null);
|
||
setTrackOpen(true);
|
||
}
|
||
|
||
async function openDetail(row: Row) {
|
||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||
setDetail(d);
|
||
editForm.setFieldsValue({
|
||
provider: d.provider,
|
||
trackingNo: d.trackingNo,
|
||
providerOrderNo: d.providerOrderNo,
|
||
});
|
||
setDrawerOpen(true);
|
||
}
|
||
|
||
const baseColumns: ColumnsType<Row> = [
|
||
{
|
||
title: '订单号',
|
||
dataIndex: ['order', 'orderNo'],
|
||
width: 170,
|
||
render: (v, row) => (
|
||
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
||
),
|
||
},
|
||
{
|
||
title: '用户',
|
||
key: 'user',
|
||
width: 140,
|
||
render: (_, row) => {
|
||
const user = row.order?.user;
|
||
if (!user) return '—';
|
||
const label = user.userNo || user.phone || '—';
|
||
return (
|
||
<div>
|
||
{user.id ? (
|
||
<AdminPrimaryLink
|
||
onClick={() => navigate('/users', { state: { openUserId: String(user.id) } })}
|
||
>
|
||
{label}
|
||
</AdminPrimaryLink>
|
||
) : (
|
||
label
|
||
)}
|
||
{user.phone && user.userNo ? (
|
||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||
{user.phone}
|
||
</Typography.Text>
|
||
) : null}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '商品',
|
||
key: 'product',
|
||
width: 200,
|
||
render: (_, row) => {
|
||
const order = row.order;
|
||
if (!order?.productName) return '—';
|
||
return (
|
||
<div>
|
||
<span>{order.productName}</span>
|
||
{order.productSpec ? (
|
||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||
{order.productSpec}
|
||
</Typography.Text>
|
||
) : null}
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '数量',
|
||
key: 'quantity',
|
||
width: 90,
|
||
render: (_, row) => formatQty(row.order),
|
||
},
|
||
{
|
||
title: '配送方式',
|
||
dataIndex: ['order', 'deliveryType'],
|
||
width: 90,
|
||
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
|
||
},
|
||
{
|
||
title: '承运商',
|
||
dataIndex: 'provider',
|
||
width: 90,
|
||
render: (v: string) => DELIVERY_PROVIDER_LABELS[v] || v || '—',
|
||
},
|
||
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
||
{
|
||
title: '运费',
|
||
dataIndex: 'logisticsFee',
|
||
width: 90,
|
||
render: (v: number | null | undefined) => (v == null ? '—' : `¥${Number(v).toFixed(2)}`),
|
||
},
|
||
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
||
{
|
||
title: '订单状态',
|
||
dataIndex: ['order', 'status'],
|
||
width: 100,
|
||
render: (s) => ORDER_STATUS_LABELS[s] || s,
|
||
},
|
||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||
{
|
||
title: '收货电话',
|
||
dataIndex: ['order', 'receiverPhone'],
|
||
width: 120,
|
||
render: (v) => v || '—',
|
||
},
|
||
{
|
||
title: '配送地址',
|
||
key: 'address',
|
||
width: 280,
|
||
ellipsis: true,
|
||
render: (_, row) => formatAddress(row.order),
|
||
},
|
||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||
{
|
||
title: '操作', width: 140,
|
||
render: (_, row) => (
|
||
<Space size={0}>
|
||
<Button type="link" size="small" onClick={() => openTrack(row)}>路由</Button>
|
||
<Button type="link" size="small" onClick={() => void openDetail(row)}>编辑</Button>
|
||
</Space>
|
||
),
|
||
},
|
||
];
|
||
|
||
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
|
||
|
||
const order = detail?.order;
|
||
|
||
return (
|
||
<div>
|
||
{settingsModal}
|
||
<AdminListHeader title="快递/配送单" settings={settingsButton} />
|
||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
||
<Form.Item name="provider" label="承运商"><Input allowClear placeholder="XFX" /></Form.Item>
|
||
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
|
||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||
</Form>
|
||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||
<Drawer title="配送单编辑" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||
extra={
|
||
<Space>
|
||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||
<Button type="primary" onClick={async () => {
|
||
if (!detail) return;
|
||
const v = await editForm.validateFields();
|
||
await request(`/admin/deliveries/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||
message.success('已保存');
|
||
setDrawerOpen(false);
|
||
void reload();
|
||
}}>保存</Button>
|
||
</Space>
|
||
}>
|
||
{detail && (
|
||
<>
|
||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||
<Descriptions.Item label="订单">{order?.orderNo || '—'}</Descriptions.Item>
|
||
<Descriptions.Item label="用户">
|
||
{order?.user ? (
|
||
<Space size={8} wrap>
|
||
{order.user.id ? (
|
||
<AdminPrimaryLink
|
||
onClick={() => navigate('/users', { state: { openUserId: String(order.user!.id) } })}
|
||
>
|
||
{order.user.userNo}
|
||
</AdminPrimaryLink>
|
||
) : (
|
||
order.user.userNo || '—'
|
||
)}
|
||
<Typography.Text type="secondary">
|
||
{[order.user.nickname, order.user.phone].filter(Boolean).join(' / ') || ''}
|
||
</Typography.Text>
|
||
</Space>
|
||
) : '—'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="商品">
|
||
<Space align="start">
|
||
{order?.imageResource?.url ? (
|
||
<img
|
||
src={order.imageResource.url}
|
||
alt=""
|
||
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 4 }}
|
||
/>
|
||
) : null}
|
||
<span>
|
||
{order?.productName || '—'}
|
||
{order?.productSpec ? (
|
||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||
{order.productSpec}
|
||
</Typography.Text>
|
||
) : null}
|
||
{order?.barcode69 ? (
|
||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||
{order.barcode69}
|
||
</Typography.Text>
|
||
) : null}
|
||
</span>
|
||
</Space>
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="数量">{formatQty(order)}</Descriptions.Item>
|
||
<Descriptions.Item label="配送方式">
|
||
{DELIVERY_TYPE_LABELS[order?.deliveryType ?? ''] || order?.deliveryType || '—'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="收货人">
|
||
{[order?.receiverName, order?.receiverPhone].filter(Boolean).join(' ') || '—'}
|
||
</Descriptions.Item>
|
||
<Descriptions.Item label="配送地址">{formatAddress(order)}</Descriptions.Item>
|
||
<Descriptions.Item label="运费">
|
||
{detail.logisticsFee == null ? '—' : `¥${Number(detail.logisticsFee).toFixed(2)}`}
|
||
</Descriptions.Item>
|
||
</Descriptions>
|
||
<Form form={editForm} layout="vertical">
|
||
<Form.Item name="provider" label="承运商" rules={[{ required: true }]}><Input /></Form.Item>
|
||
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
||
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
||
</Form>
|
||
<Button
|
||
style={{ marginTop: 8 }}
|
||
onClick={() => openTrack(detail)}
|
||
>
|
||
查看路由
|
||
</Button>
|
||
</>
|
||
)}
|
||
</Drawer>
|
||
<OrderTrackDrawer
|
||
open={trackOpen}
|
||
orderId={trackOrderId}
|
||
orderNo={trackOrderNo}
|
||
orderStatus={trackOrderStatus}
|
||
onClose={() => setTrackOpen(false)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|