feat(admin): 订单页筛选改版并支持用户与商品模糊搜索

收起次要筛选项,去掉过滤测试;列表可按用户编号/昵称/备注/手机与商品名称/规格查询。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 13:10:36 +08:00
parent fccfd7abbe
commit b226af2adc
5 changed files with 223 additions and 109 deletions
+50
View File
@@ -74,6 +74,56 @@ body.admin-col-resizing * {
user-select: none !important;
}
/* 订单页筛选:第一行常驻,第二行起可收起 */
.admin-orders-filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
align-items: flex-end;
margin-bottom: 12px;
}
.admin-orders-filter-row--tools {
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.admin-orders-filter-item {
margin-bottom: 0 !important;
}
.admin-orders-filter-item--sm {
width: 140px;
}
.admin-orders-filter-item--md {
width: 168px;
}
.admin-orders-filter-item--search {
width: 180px;
}
.admin-orders-filter-item--status {
width: 220px;
min-width: 180px;
flex: 1 1 180px;
max-width: 280px;
}
.admin-orders-filter-item--date {
flex: 1 1 auto;
}
.admin-orders-filter-actions {
margin-left: auto;
}
.admin-orders-date-picker {
width: 240px;
}
/* 列表页顶栏:标题左、主操作右、列设置最右 */
.admin-list-header {
display: flex;
+131 -109
View File
@@ -4,7 +4,6 @@ import {
Alert,
Button,
Checkbox,
Col,
Collapse,
DatePicker,
Descriptions,
@@ -14,7 +13,6 @@ import {
InputNumber,
Modal,
Radio,
Row,
Select,
Space,
Table,
@@ -24,6 +22,7 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns';
@@ -150,8 +149,9 @@ type OrderExportFilters = {
orderType?: string;
cityId?: string;
receiverPhone?: string;
userKeyword?: string;
productKeyword?: string;
fulfillmentHold?: boolean;
excludeTest?: boolean;
deliveryType?: string;
promoCodeId?: string;
dateRange?: [Dayjs, Dayjs];
@@ -213,6 +213,8 @@ const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year';
{ key: 'year', label: '当年' },
];
const FILTERS_COLLAPSED_KEY = 'admin-orders-filters-collapsed';
function formatUserWithRemark(user?: {
userNo?: string;
hqRemark?: string | null;
@@ -254,9 +256,10 @@ function buildExportPayload(
if (filters.orderType) payload.orderType = filters.orderType;
if (filters.cityId) payload.cityId = filters.cityId;
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
if (filters.userKeyword) payload.userKeyword = filters.userKeyword;
if (filters.productKeyword) payload.productKeyword = filters.productKeyword;
if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
if (filters.fulfillmentHold) payload.fulfillmentHold = true;
if (filters.excludeTest) payload.excludeTest = true;
if (filters.promoCodeId) payload.promoCodeId = filters.promoCodeId;
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');
@@ -335,6 +338,13 @@ export default function OrdersPage() {
const [trackOpen, setTrackOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx');
const [filtersCollapsed, setFiltersCollapsed] = useState(() => {
try {
return localStorage.getItem(FILTERS_COLLAPSED_KEY) === '1';
} catch {
return false;
}
});
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
@@ -404,8 +414,9 @@ export default function OrdersPage() {
if (values.orderType) qs.set('orderType', values.orderType);
if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.userKeyword) qs.set('userKeyword', values.userKeyword);
if (values.productKeyword) qs.set('productKeyword', values.productKeyword);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.excludeTest) qs.set('excludeTest', 'true');
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId);
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
@@ -843,14 +854,72 @@ export default function OrdersPage() {
) : null}
<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>
<div className="admin-orders-filter-row">
<Form.Item name="cityId" label="城市" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="receiverPhone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
<Input allowClear placeholder="手机号" />
</Form.Item>
<Form.Item name="userKeyword" label="用户" className="admin-orders-filter-item admin-orders-filter-item--search">
<Input allowClear placeholder="编号/昵称/备注/手机" />
</Form.Item>
<Form.Item name="productKeyword" label="商品" className="admin-orders-filter-item admin-orders-filter-item--search">
<Input allowClear placeholder="名称/规格" />
</Form.Item>
<Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--status">
<Select
mode="multiple"
allowClear
maxTagCount="responsive"
placeholder="全部"
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="orderType" label="类型" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="deliveryType" label="配送" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
</Form.Item>
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
<Space size={8} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button
type="link"
icon={filtersCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => {
setFiltersCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(FILTERS_COLLAPSED_KEY, next ? '1' : '0');
} catch {
/* ignore */
}
return next;
});
}}
>
{filtersCollapsed ? '展开' : '收起'}
</Button>
</Space>
</Form.Item>
</div>
{!filtersCollapsed ? (
<>
<div className="admin-orders-filter-row">
<Form.Item label="下单日期" className="admin-orders-filter-item admin-orders-filter-item--date">
<Space size={8} wrap>
<Form.Item name="dateRange" noStyle>
<DatePicker.RangePicker allowClear className="admin-orders-date-picker" />
</Form.Item>
{DATE_PRESETS.map((preset) => (
<Button
key={preset.key}
@@ -861,107 +930,60 @@ export default function OrdersPage() {
</Button>
))}
</Space>
</Space>
</Form.Item>
</Col>
<Col xs={24} sm={12} md={8} lg={6}>
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
<Select
mode="multiple"
allowClear
maxTagCount="responsive"
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>
</div>
<div className="admin-orders-filter-row admin-orders-filter-row--tools">
{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>
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
</Form.Item>
<Button
onClick={() => {
form.resetFields();
setPage(1);
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
else void load();
}}
>
</Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</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);
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
else void load();
}}
>
</Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</Space>
</div>
</div>
</>
) : null}
</Form>
<Table
+2
View File
@@ -9,6 +9,8 @@ export interface AdminOrdersListQuery extends AdminListQuery {
status?: string | string[];
orderType?: string;
userId?: string;
userKeyword?: string;
productKeyword?: string;
cityId?: string;
receiverPhone?: string;
fulfillmentHold?: string | boolean;
@@ -30,6 +30,8 @@ type OrderFilterInput = Pick<
| 'status'
| 'orderType'
| 'userId'
| 'userKeyword'
| 'productKeyword'
| 'cityId'
| 'receiverPhone'
| 'fulfillmentHold'
@@ -177,8 +179,28 @@ export class AdminOrdersService {
}
if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
if (query.userId) where.userId = BigInt(query.userId);
const userKeyword = query.userKeyword?.trim();
if (userKeyword) {
const userOr: Prisma.UserWhereInput[] = [
{ userNo: { contains: userKeyword } },
{ nickname: { contains: userKeyword } },
{ hqRemark: { contains: userKeyword } },
{ phone: { contains: userKeyword } },
];
if (/^\d+$/.test(userKeyword)) {
userOr.push({ id: BigInt(userKeyword) });
}
where.user = { OR: userOr };
}
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
const productKeyword = query.productKeyword?.trim();
if (productKeyword) {
where.OR = [
{ productName: { contains: productKeyword } },
{ productSpec: { contains: productKeyword } },
];
}
if (query.deliveryType) {
where.deliveryType = query.deliveryType as Prisma.EnumDeliveryTypeFilter['equals'];
}
@@ -93,6 +93,16 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
@IsString()
userId?: string;
/** 用户编号 / 昵称 / 备注 / 手机 模糊匹配 */
@IsOptional()
@IsString()
userKeyword?: string;
/** 商品名称 / 规格 模糊匹配 */
@IsOptional()
@IsString()
productKeyword?: string;
@IsOptional()
@IsString()
cityId?: string;
@@ -159,6 +169,14 @@ export class AdminOrdersExportDto {
@IsString()
cityId?: string;
@IsOptional()
@IsString()
userKeyword?: string;
@IsOptional()
@IsString()
productKeyword?: string;
@IsOptional()
@IsString()
receiverPhone?: string;