v3.5.5版本上传
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-08-23 12:26:57 +08:00
parent f9161368e1
commit 22cd00da42
18 changed files with 2036 additions and 294 deletions
+302 -117
View File
@@ -4,7 +4,9 @@ import {
Alert,
Button,
Checkbox,
Col,
Collapse,
DatePicker,
Descriptions,
Drawer,
Form,
@@ -12,6 +14,7 @@ import {
InputNumber,
Modal,
Radio,
Row,
Select,
Space,
Table,
@@ -20,7 +23,10 @@ import {
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs';
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
import { downloadBase64File } from '../lib/exportExcel';
import {
ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS,
@@ -130,6 +136,110 @@ type OrderDetail = AdminOrderRow & {
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
type OrderExportScope = 'filter' | 'selected';
type OrderExportFormat = 'xlsx' | 'pdf';
type OrderExportFilters = {
orderNo?: string;
status?: string;
orderType?: string;
cityId?: string;
receiverPhone?: string;
fulfillmentHold?: boolean;
excludeTest?: boolean;
deliveryType?: string;
dateRange?: [Dayjs, Dayjs];
};
type OrderExportResult = {
filename: string;
mimeType: string;
contentBase64: string;
count: number;
};
const ORDER_TYPE_OPTIONS = Object.entries(ORDER_TYPE_LABELS).map(([value, label]) => ({ value, label }));
const DELIVERY_TYPE_OPTIONS = [
{ value: 'LOCAL', label: '同城' },
{ value: 'CROSS_CITY', label: '跨城' },
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
];
function isOnSitePickupOrder(row: Pick<AdminOrderRow, 'deliveryType' | 'receiverProvince' | 'receiverCity' | 'receiverAddress'>) {
return (
row.deliveryType === 'ON_SITE_PICKUP' ||
row.receiverAddress === '现场取货' ||
row.receiverAddress === '现场提货' ||
(row.receiverProvince === '现场' && row.receiverCity === '现场')
);
}
function formatReceiverAddress(row: AdminOrderRow) {
if (isOnSitePickupOrder(row)) return '现场取货';
const region = [row.receiverProvince, row.receiverCity, row.receiverDistrict].filter(Boolean).join('');
const detail = row.receiverAddress || '';
if (!region) return detail;
if (!detail || detail.startsWith(region)) return detail || region;
return `${region}${detail}`;
}
function datePresetRange(kind: 'day' | 'week' | 'month' | 'quarter' | 'year'): [Dayjs, Dayjs] {
const now = dayjs();
if (kind === 'day') return [now.startOf('day'), now.endOf('day')];
if (kind === 'week') {
const monday = now.startOf('day').subtract((now.day() + 6) % 7, 'day');
return [monday, monday.add(6, 'day').endOf('day')];
}
if (kind === 'month') return [now.startOf('month'), now.endOf('month')];
if (kind === 'quarter') {
const start = now.month(Math.floor(now.month() / 3) * 3).startOf('month');
return [start, start.add(2, 'month').endOf('month')];
}
return [now.startOf('year'), now.endOf('year')];
}
const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year'; label: string }> = [
{ key: 'day', label: '当日' },
{ key: 'week', label: '当周' },
{ key: 'month', label: '当月' },
{ key: 'quarter', label: '当季' },
{ key: 'year', label: '当年' },
];
function formatBenefitBrief(row: AdminOrderRow) {
const coupon = row.benefitCoupon;
if (coupon) {
return `总额¥${Number(coupon.totalAmount ?? 0).toFixed(0)} / 已用¥${Number(coupon.usedAmount ?? 0).toFixed(0)} / 余¥${Number(coupon.balance ?? 0).toFixed(0)}`;
}
if (row.benefitAmount != null) return `¥${Number(row.benefitAmount).toFixed(0)}`;
return '—';
}
function buildExportPayload(
scope: OrderExportScope,
format: OrderExportFormat,
filters: OrderExportFilters,
selectedIds: string[],
) {
const payload: Record<string, unknown> = { scope, format };
if (scope === 'selected') {
payload.ids = selectedIds;
return payload;
}
if (filters.orderNo) payload.orderNo = filters.orderNo;
if (filters.status) payload.status = filters.status;
if (filters.orderType) payload.orderType = filters.orderType;
if (filters.cityId) payload.cityId = filters.cityId;
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
if (filters.fulfillmentHold) payload.fulfillmentHold = true;
if (filters.excludeTest) payload.excludeTest = true;
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
return payload;
}
function orderProductRows(detail: OrderDetail): AdminOrderItem[] {
if (detail.items?.length) return detail.items;
if (!detail.productName) return [];
@@ -192,8 +302,11 @@ export default function OrdersPage() {
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [proxyOpen, setProxyOpen] = useState(false);
const [trackOpen, setTrackOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx');
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
@@ -238,6 +351,7 @@ export default function OrdersPage() {
try {
const values = form.getFieldsValue();
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (initialOrderNo) qs.set('orderNo', initialOrderNo);
if (values.orderNo) qs.set('orderNo', values.orderNo);
if (values.status) qs.set('status', values.status);
if (values.orderType) qs.set('orderType', values.orderType);
@@ -245,12 +359,15 @@ export default function OrdersPage() {
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.excludeTest) qs.set('excludeTest', 'true');
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res);
} finally {
setLoading(false);
}
}, [form, page, pageSize]);
}, [form, page, pageSize, initialOrderNo]);
useEffect(() => {
void load();
@@ -451,79 +568,93 @@ export default function OrdersPage() {
}
}
async function submitExport(scope: OrderExportScope) {
const values = form.getFieldsValue() as OrderExportFilters;
if (scope === 'selected' && !selectedRowKeys.length) {
message.warning('请先勾选要导出的订单');
return;
}
setExporting(true);
try {
const payload = buildExportPayload(scope, exportFormat, values, selectedRowKeys);
const result = await request<OrderExportResult>('/admin/orders/export', {
method: 'POST',
body: JSON.stringify(payload),
});
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
message.success(`已导出 ${result.count} 条订单`);
} catch (e) {
message.error(e instanceof Error ? e.message : '导出失败');
} finally {
setExporting(false);
}
}
const columns: ColumnsType<AdminOrderRow> = [
{
title: '订单号',
dataIndex: 'orderNo',
width: 200,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
title: '商品',
width: 240,
render: (_, row) => (
<div>
<Space size={4} wrap>
<span>{row.productName || '—'}</span>
{row.isTest ? <Tag color="orange"></Tag> : null}
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
{row.orderType === 'PROXY' || row.isProxyOrder ? (
<Tag color="purple"></Tag>
) : null}
</Space>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{[row.productSpec, row.quantity != null ? `×${row.quantity}` : null]
.filter(Boolean)
.join(' ')}
</Typography.Text>
</div>
</div>
),
},
{
title: '城市',
width: 90,
render: (_, row) => row.city?.name || '—',
title: '状态 / 实付',
width: 130,
render: (_, row) => (
<div>
<Tag color={ORDER_STATUS_COLORS[row.status] || 'default'}>
{ORDER_STATUS_LABELS[row.status] || row.status}
</Tag>
<div>¥{row.payAmount}</div>
</div>
),
},
{
title: '商品',
width: 180,
ellipsis: true,
title: '好客权益',
width: 200,
render: (_, row) => formatBenefitBrief(row),
},
{
title: '收货信息',
width: 240,
render: (_, row) => {
const name = row.productName || '—';
const qty = row.quantity != null ? ` ×${row.quantity}` : '';
const address = formatReceiverAddress(row);
return (
<span title={row.productSpec ? `${name}${row.productSpec}` : name}>
{name}{qty}
</span>
<div>
<div>
{row.receiverName || '—'} {row.receiverPhone || ''}
</div>
{address ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{address}
</Typography.Text>
) : null}
</div>
);
},
},
{
title: '状态',
dataIndex: 'status',
width: 140,
render: (s, row) => (
<Space size={4} wrap>
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
{row.orderType === 'PROXY' || row.isProxyOrder ? (
<Tag color="purple" title={row.proxyPartnerName || undefined}>
</Tag>
) : null}
</Space>
),
},
{
title: '配送',
dataIndex: 'deliveryType',
width: 90,
render: (v) =>
v === 'ON_SITE_PICKUP' ? '现场提货' : 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'),
render: fmtTime,
},
{
title: '操作',
@@ -557,24 +688,15 @@ export default function OrdersPage() {
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Space>
<Space size={12}>
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}></Button>
{canProxyOrder ? (
<Button type="primary" onClick={() => setProxyOpen(true)}>
</Button>
) : null}
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space>
</Space>
@@ -592,54 +714,115 @@ export default function OrdersPage() {
/>
) : null}
<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="orderType" label="类型">
<Select
allowClear
style={{ width: 120 }}
placeholder="全部"
options={[
{ value: 'NORMAL', label: '普通订单' },
{ value: 'PROXY', label: '代下单' },
]}
/>
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="receiverPhone" label="收货手机">
<Input allowClear />
</Form.Item>
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
<Select
allowClear
style={{ width: 140 }}
placeholder="全部"
options={[{ value: true, label: '仅待确认大单' }]}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
<Row gutter={[16, 8]}>
<Col xs={24} md={14} lg={12}>
<Form.Item label="下单日期" style={{ marginBottom: 12 }}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Form.Item name="dateRange" noStyle>
<DatePicker.RangePicker allowClear style={{ width: '100%' }} />
</Form.Item>
<Space size={8} wrap>
{DATE_PRESETS.map((preset) => (
<Button
key={preset.key}
size="small"
onClick={() => form.setFieldsValue({ dateRange: datePresetRange(preset.key) })}
>
{preset.label}
</Button>
))}
</Space>
</Space>
</Form.Item>
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
<Select
allowClear
placeholder="全部"
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
</Form.Item>
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
</Form.Item>
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6} lg={6}>
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
<Input allowClear placeholder="手机号" />
</Form.Item>
</Col>
<Col xs={12} sm={8} md={8} lg={8}>
<Form.Item label=" " colon={false} style={{ marginBottom: 12 }}>
<Space size={16} wrap>
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
</Space>
</Form.Item>
</Col>
</Row>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}>
{canExportOrders ? (
<Space size={12} wrap>
<Radio.Group
optionType="button"
buttonStyle="solid"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
>
<Radio.Button value="xlsx">Excel</Radio.Button>
<Radio.Button value="pdf">PDF</Radio.Button>
</Radio.Group>
<Button
disabled={!selectedRowKeys.length}
loading={exporting}
onClick={() => void submitExport('selected')}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
<Button loading={exporting} onClick={() => void submitExport('filter')}>
</Button>
</Space>
) : <span />}
<Space size={12} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</Space>
</Form.Item>
</div>
</Form>
<Table
@@ -647,16 +830,18 @@ export default function OrdersPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1500 }}
rowSelection={canDeleteOrders ? {
scroll={{ x: 1100 }}
rowSelection={{
selectedRowKeys,
preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
} : undefined}
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);