@@ -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 ) ;