Files
dukang/apps/admin-web/src/pages/OrdersPage.tsx
T
2026-07-06 21:36:20 +08:00

457 lines
17 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;
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 [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 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 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="快递公司">
{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>
</Descriptions>
)}
{['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && (
<div style={{ marginTop: 16 }}>
<Typography.Title level={5}>发货</Typography.Title>
<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>
);
}