feat(Shop): 添加批量编辑功能和多处界面优化

refactor(Orders): 重构订单详情为编辑表单并添加批量编辑
feat(Customers): 实现客户批量编辑功能
feat(Logistics): 添加批量删除和批量打印功能
feat(SiteList): 实现站点数据同步功能
style(Forms): 优化表单按钮样式和布局
This commit is contained in:
tikkhun 2025-12-11 15:06:35 +08:00
parent db0bea991c
commit d40f157b78
7 changed files with 436 additions and 109 deletions

View File

@ -10,7 +10,7 @@ import {
ProTable, ProTable,
} from '@ant-design/pro-components'; } from '@ant-design/pro-components';
import { request } from '@umijs/max'; import { request } from '@umijs/max';
import { Button, message, Popconfirm, Space, Tag } from 'antd'; import { Button, message, notification, Popconfirm, Space, Tag } from 'antd';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
// 区域数据项类型 // 区域数据项类型
@ -58,6 +58,62 @@ const SiteList: React.FC = () => {
const formRef = useRef<ProFormInstance>(); const formRef = useRef<ProFormInstance>();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<SiteItem | null>(null); const [editing, setEditing] = useState<SiteItem | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const handleSync = async (ids: number[]) => {
if (!ids.length) return;
const hide = message.loading('正在同步...', 0);
const stats = {
products: { success: 0, fail: 0 },
orders: { success: 0, fail: 0 },
subscriptions: { success: 0, fail: 0 },
};
try {
for (const id of ids) {
// 同步产品
const prodRes = await request(`/wp_product/sync/${id}`, { method: 'POST' });
if (prodRes) {
stats.products.success += prodRes.successCount || 0;
stats.products.fail += prodRes.failureCount || 0;
}
// 同步订单
const orderRes = await request(`/order/syncOrder/${id}`, { method: 'POST' });
if (orderRes) {
stats.orders.success += orderRes.successCount || 0;
stats.orders.fail += orderRes.failureCount || 0;
}
// 同步订阅
const subRes = await request(`/subscription/sync/${id}`, { method: 'POST' });
if (subRes) {
stats.subscriptions.success += subRes.successCount || 0;
stats.subscriptions.fail += subRes.failureCount || 0;
}
}
hide();
notification.success({
message: '同步完成',
description: (
<div>
<p>产品: 成功 {stats.products.success}, {stats.products.fail}</p>
<p>订单: 成功 {stats.orders.success}, {stats.orders.fail}</p>
<p>订阅: 成功 {stats.subscriptions.success}, {stats.subscriptions.fail}</p>
</div>
),
duration: null, // 不自动关闭
});
setSelectedRowKeys([]);
actionRef.current?.reload();
} catch (error: any) {
hide();
message.error(error.message || '同步失败');
}
};
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -174,6 +230,13 @@ const SiteList: React.FC = () => {
hideInSearch: true, hideInSearch: true,
render: (_, row) => ( render: (_, row) => (
<Space> <Space>
<Button
size="small"
type="primary"
onClick={() => handleSync([row.id])}
>
</Button>
<Button <Button
size="small" size="small"
onClick={() => { onClick={() => {
@ -321,6 +384,10 @@ const SiteList: React.FC = () => {
rowKey="id" rowKey="id"
columns={columns} columns={columns}
request={tableRequest} request={tableRequest}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
toolBarRender={() => [ toolBarRender={() => [
<Button <Button
key="new" key="new"
@ -333,10 +400,14 @@ const SiteList: React.FC = () => {
</Button>, </Button>,
// 同步包括 orders subscriptions 等等 // 同步包括 orders subscriptions 等等
<Button key='new' disabled type='primary' onClick={()=> { <Button
}}> key="sync"
disabled={!selectedRowKeys.length}
type="primary"
onClick={() => handleSync(selectedRowKeys as number[])}
>
</Button> </Button>,
]} ]}
/> />

View File

@ -1,9 +1,60 @@
import { ActionType, DrawerForm, PageContainer, ProColumns, ProFormText, ProTable } from '@ant-design/pro-components'; import { ActionType, DrawerForm, ModalForm, PageContainer, ProColumns, ProFormText, ProTable } from '@ant-design/pro-components';
import { request, useParams } from '@umijs/max'; import { request, useParams } from '@umijs/max';
import { App, Avatar, Button, Popconfirm, Space, Tag } from 'antd'; import { App, Avatar, Button, Popconfirm, Space, Tag } from 'antd';
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { DeleteFilled, EditOutlined, PlusOutlined, UserOutlined } from '@ant-design/icons'; import { DeleteFilled, EditOutlined, PlusOutlined, UserOutlined } from '@ant-design/icons';
const BatchEditCustomers: React.FC<{
tableRef: React.MutableRefObject<ActionType | undefined>;
selectedRowKeys: React.Key[];
setSelectedRowKeys: (keys: React.Key[]) => void;
siteId?: string;
}> = ({ tableRef, selectedRowKeys, setSelectedRowKeys, siteId }) => {
const { message } = App.useApp();
return (
<ModalForm
title="批量编辑客户"
trigger={
<Button
disabled={!selectedRowKeys.length}
type="primary"
icon={<EditOutlined />}
>
</Button>
}
width={400}
modalProps={{ destroyOnHidden: true }}
onFinish={async (values) => {
if (!siteId) return false;
let ok = 0, fail = 0;
for (const id of selectedRowKeys) {
try {
// Remove undefined values
const data = Object.fromEntries(Object.entries(values).filter(([_, v]) => v !== undefined && v !== ''));
if (Object.keys(data).length === 0) continue;
const res = await request(`/site-api/${siteId}/customers/${id}`, {
method: 'PUT',
data: data,
});
if (res.success) ok++;
else fail++;
} catch (e) {
fail++;
}
}
message.success(`成功 ${ok}, 失败 ${fail}`);
tableRef.current?.reload();
setSelectedRowKeys([]);
return true;
}}
>
<ProFormText name="role" label="角色" placeholder="请输入角色,不修改请留空" />
</ModalForm>
);
};
const CustomerPage: React.FC = () => { const CustomerPage: React.FC = () => {
const { message } = App.useApp(); const { message } = App.useApp();
const { siteId } = useParams<{ siteId: string }>(); const { siteId } = useParams<{ siteId: string }>();
@ -167,10 +218,11 @@ const CustomerPage: React.FC = () => {
<ProFormText name="last_name" label="姓" /> <ProFormText name="last_name" label="姓" />
<ProFormText name="username" label="用户名" /> <ProFormText name="username" label="用户名" />
</DrawerForm>, </DrawerForm>,
<Button <BatchEditCustomers
title="批量编辑" tableRef={actionRef}
icon={<EditOutlined />} selectedRowKeys={selectedRowKeys}
onClick={() => message.info('批量编辑暂未实现')} setSelectedRowKeys={setSelectedRowKeys}
siteId={siteId}
/>, />,
<Button <Button
title="批量删除" title="批量删除"

View File

@ -15,7 +15,7 @@ import {
ProTable, ProTable,
} from '@ant-design/pro-components'; } from '@ant-design/pro-components';
import { useParams } from '@umijs/max'; import { useParams } from '@umijs/max';
import { App, Button, Divider, Popconfirm } from 'antd'; import { App, Button, Divider, Popconfirm, Space } from 'antd';
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
@ -198,16 +198,40 @@ const LogisticsPage: React.FC = () => {
success: false, success: false,
}; };
}} }}
// rowSelection={{ rowSelection={{
// selectedRowKeys: selectedRows.map((row) => row.id), selectedRowKeys: selectedRows.map((row) => row.id),
// onChange: (_, selectedRows) => setSelectedRows(selectedRows), onChange: (_, selectedRows) => setSelectedRows(selectedRows),
// }} }}
columns={columns} columns={columns}
tableAlertOptionRender={() => { tableAlertOptionRender={() => {
return ( return (
<Space>
<Button onClick={handleBatchPrint} type="primary"> <Button onClick={handleBatchPrint} type="primary">
</Button> </Button>
<Button
danger
type="primary"
onClick={async () => {
try {
setIsLoading(true);
let ok = 0;
for (const row of selectedRows) {
const { success } = await logisticscontrollerDeleteshipment({ id: row.id });
if (success) ok++;
}
message.success(`成功删除 ${ok}`);
setIsLoading(false);
actionRef.current?.reload();
setSelectedRows([]);
} catch (e) {
setIsLoading(false);
}
}}
>
</Button>
</Space>
); );
}} }}
/> />

View File

@ -27,7 +27,7 @@ import {
Tag, Tag,
} from 'antd'; } from 'antd';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { CreateOrder, Detail, OrderNote, Shipping } from '../components/Order/Forms'; import { BatchEditOrders, CreateOrder, EditOrder, OrderNote, Shipping } from '../components/Order/Forms';
import { DeleteFilled } from '@ant-design/icons'; import { DeleteFilled } from '@ant-design/icons';
const OrdersPage: React.FC = () => { const OrdersPage: React.FC = () => {
@ -122,9 +122,20 @@ const OrdersPage: React.FC = () => {
render: (_, record) => record.shipping?.phone || record.billing?.phone, render: (_, record) => record.shipping?.phone || record.billing?.phone,
}, },
{ {
title: '州', title: '账单地址',
dataIndex: 'billing_full_address',
hideInSearch: true, hideInSearch: true,
render: (_, record) => record.shipping?.state || record.billing?.state, width: 200,
ellipsis: true,
copyable: true,
},
{
title: '收货地址',
dataIndex: 'shipping_full_address',
hideInSearch: true,
width: 200,
ellipsis: true,
copyable: true,
}, },
{ {
title: '操作', title: '操作',
@ -135,7 +146,7 @@ const OrdersPage: React.FC = () => {
render: (_, record) => { render: (_, record) => {
return ( return (
<> <>
<Detail <EditOrder
key={record.id} key={record.id}
record={record} record={record}
tableRef={actionRef} tableRef={actionRef}
@ -152,7 +163,7 @@ const OrdersPage: React.FC = () => {
key: 'history', key: 'history',
label: ( label: (
<HistoryOrder <HistoryOrder
email={record.email} email={(record as any).email}
tableRef={actionRef} tableRef={actionRef}
/> />
), ),
@ -165,10 +176,8 @@ const OrdersPage: React.FC = () => {
}} }}
> >
<a onClick={(e) => e.preventDefault()}> <a onClick={(e) => e.preventDefault()}>
<Space> <Button type="link" icon={<DownOutlined />}>
</Button>
<DownOutlined />
</Space>
</a> </a>
</Dropdown> </Dropdown>
<Divider type="vertical" /> <Divider type="vertical" />
@ -217,6 +226,12 @@ const OrdersPage: React.FC = () => {
}} }}
toolBarRender={() => [ toolBarRender={() => [
<CreateOrder tableRef={actionRef} siteId={siteId} />, <CreateOrder tableRef={actionRef} siteId={siteId} />,
<BatchEditOrders
tableRef={actionRef}
selectedRowKeys={selectedRowKeys}
setSelectedRowKeys={setSelectedRowKeys}
siteId={siteId}
/>,
<Button <Button
title="批量删除" title="批量删除"
danger danger

View File

@ -216,7 +216,7 @@ const ProductsPage: React.FC = () => {
]; ];
return ( return (
<PageContainer> <PageContainer header={{ title: null, breadcrumb: undefined }}>
<ProTable<any> <ProTable<any>
scroll={{ x: 'max-content' }} scroll={{ x: 'max-content' }}
pagination={{ pagination={{

View File

@ -15,11 +15,13 @@ import {
CodeSandboxOutlined, CodeSandboxOutlined,
CopyOutlined, CopyOutlined,
DeleteFilled, DeleteFilled,
EditOutlined,
FileDoneOutlined, FileDoneOutlined,
TagsOutlined, TagsOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
ActionType, ActionType,
DrawerForm,
ModalForm, ModalForm,
ProColumns, ProColumns,
ProDescriptions, ProDescriptions,
@ -78,7 +80,11 @@ export const OrderNote: React.FC<{
return ( return (
<ModalForm <ModalForm
title="添加备注" title="添加备注"
trigger={<Button type="primary" ghost title="备注" icon={<TagsOutlined />} />} trigger={
<Button type="primary" ghost size="small" icon={<TagsOutlined />}>
</Button>
}
onFinish={async (values: any) => { onFinish={async (values: any) => {
if (!siteId) { if (!siteId) {
message.error('缺少站点ID'); message.error('缺少站点ID');
@ -236,12 +242,14 @@ export const Shipping: React.FC<{
trigger={ trigger={
<Button <Button
type="primary" type="primary"
title="创建运单" size="small"
icon={<CodeSandboxOutlined />} icon={<CodeSandboxOutlined />}
onClick={() => { onClick={() => {
setActiveLine(id); setActiveLine(id);
}} }}
/> >
</Button>
} }
request={async () => { request={async () => {
if (!siteId) return {}; if (!siteId) return {};
@ -569,7 +577,11 @@ export const CreateOrder: React.FC<{
body: { maxHeight: '65vh', overflowY: 'auto', overflowX: 'hidden' }, body: { maxHeight: '65vh', overflowY: 'auto', overflowX: 'hidden' },
}, },
}} }}
trigger={<Button type="primary" title="创建订单" icon={<CodeSandboxOutlined />} />} trigger={
<Button type="primary" size="small" icon={<CodeSandboxOutlined />}>
</Button>
}
params={{ params={{
source_type: 'admin', source_type: 'admin',
}} }}
@ -618,26 +630,95 @@ export const CreateOrder: React.FC<{
); );
}; };
export const Detail: React.FC<{ export const BatchEditOrders: React.FC<{
tableRef: React.MutableRefObject<ActionType | undefined>;
selectedRowKeys: React.Key[];
setSelectedRowKeys: (keys: React.Key[]) => void;
siteId?: string;
}> = ({ tableRef, selectedRowKeys, setSelectedRowKeys, siteId }) => {
const { message } = App.useApp();
return (
<ModalForm
title="批量编辑订单"
trigger={
<Button
disabled={!selectedRowKeys.length}
type="primary"
icon={<EditOutlined />}
>
</Button>
}
width={400}
modalProps={{ destroyOnHidden: true }}
onFinish={async (values) => {
if (!siteId) return false;
let ok = 0, fail = 0;
for (const id of selectedRowKeys) {
try {
// Remove undefined values
const data = Object.fromEntries(Object.entries(values).filter(([_, v]) => v !== undefined && v !== ''));
if (Object.keys(data).length === 0) continue;
const res = await request(`/site-api/${siteId}/orders/${id}`, {
method: 'PUT',
data: data,
});
if (res.success) ok++;
else fail++;
} catch (e) {
fail++;
}
}
message.success(`成功 ${ok}, 失败 ${fail}`);
tableRef.current?.reload();
setSelectedRowKeys([]);
return true;
}}
>
<ProFormSelect
name="status"
label="状态"
valueEnum={ORDER_STATUS_ENUM}
placeholder="不修改请留空"
/>
</ModalForm>
);
};
export const EditOrder: React.FC<{
tableRef: React.MutableRefObject<ActionType | undefined>; tableRef: React.MutableRefObject<ActionType | undefined>;
orderId: number; orderId: number;
record: API.Order; record: API.Order;
setActiveLine: Function; setActiveLine: Function;
siteId?: string; siteId?: string;
}> = ({ tableRef, orderId, record, setActiveLine, siteId }) => { }> = ({ tableRef, orderId, record, setActiveLine, siteId }) => {
const [visiable, setVisiable] = useState(false);
const { message } = App.useApp(); const { message } = App.useApp();
const ref = useRef<ActionType>(); const formRef = useRef<ProFormInstance>();
const initRequest = async () => { return (
if (!siteId) return { data: {} }; <DrawerForm
formRef={formRef}
// Fetch detail from site-api title="编辑订单"
trigger={
<Button
type="primary"
size="small"
icon={<EditOutlined />}
onClick={() => setActiveLine(record.id)}
>
</Button>
}
drawerProps={{
destroyOnHidden: true,
width: '60vw',
}}
request={async () => {
if (!siteId) return {};
const { data, success } = await request(`/site-api/${siteId}/orders/${orderId}`); const { data, success } = await request(`/site-api/${siteId}/orders/${orderId}`);
if (!success || !data) return {};
if (!success || !data) return { data: {} };
// Merge sales logic
const sales = data.sales || []; const sales = data.sales || [];
const mergedSales = sales.reduce( const mergedSales = sales.reduce(
(acc: any[], cur: any) => { (acc: any[], cur: any) => {
@ -653,59 +734,90 @@ export const Detail: React.FC<{
); );
data.sales = mergedSales; data.sales = mergedSales;
return { return data;
data, }}
}; onFinish={async (values) => {
}; if (!siteId) return false;
try {
return ( const res = await request(`/site-api/${siteId}/orders/${orderId}`, {
<> method: 'PUT',
<Button data: values
key="detail" });
type="primary" if (res.success) {
title="详情" message.success('更新成功');
icon={<FileDoneOutlined />} tableRef.current?.reload();
onClick={() => { return true;
setVisiable(true); }
setActiveLine(record.id); message.error(res.message || '更新失败');
return false;
} catch(e: any) {
message.error(e.message || '更新失败');
return false;
}
}} }}
/>
<Drawer
title="订单详情"
open={visiable}
destroyOnHidden
size="large"
onClose={() => setVisiable(false)}
footer={[
<OrderNote id={orderId} descRef={ref} siteId={siteId} />,
// ... Removed Sync Button and Status change buttons (they used local controller)
// We can re-enable them if we implement status change in site-api
// I didn't implement status change (updateOrder) in controller yet.
// Wait, I implemented `updateOrder`? No, I implemented `getOrders`, `getOrder`.
// I missed `updateOrder`!
// But I implemented `updateProduct`.
// I should add `updateOrder` if I want to support status change.
// The user said "Proxy unified forwarding (various CRUD)".
// So I should have added `updateOrder`.
// But time is tight.
// I will disable the buttons for now or leave them (they will fail).
// I'll disable them to be safe.
]}
> >
<ProDescriptions <ProForm.Group title="基本信息">
labelStyle={{ width: '100px' }} <ProFormText name="number" label="订单号" readonly />
actionRef={ref} <ProFormSelect name="status" label="状态" valueEnum={ORDER_STATUS_ENUM} />
request={initRequest} <ProFormText name="currency" label="币种" readonly />
<ProFormText name="payment_method" label="支付方式" readonly />
<ProFormText name="transaction_id" label="交易ID" readonly />
<ProFormDatePicker name="date_created" label="创建时间" readonly fieldProps={{style: {width: '100%'}}} />
</ProForm.Group>
<Divider />
<ProForm.Group title="账单地址">
<ProFormText name={['billing', 'first_name']} label="名" />
<ProFormText name={['billing', 'last_name']} label="姓" />
<ProFormText name={['billing', 'company']} label="公司" />
<ProFormText name={['billing', 'address_1']} label="地址1" />
<ProFormText name={['billing', 'address_2']} label="地址2" />
<ProFormText name={['billing', 'city']} label="城市" />
<ProFormText name={['billing', 'state']} label="省/州" />
<ProFormText name={['billing', 'postcode']} label="邮编" />
<ProFormText name={['billing', 'country']} label="国家" />
<ProFormText name={['billing', 'email']} label="邮箱" />
<ProFormText name={['billing', 'phone']} label="电话" />
</ProForm.Group>
<Divider />
<ProForm.Group title="收货地址">
<ProFormText name={['shipping', 'first_name']} label="名" />
<ProFormText name={['shipping', 'last_name']} label="姓" />
<ProFormText name={['shipping', 'company']} label="公司" />
<ProFormText name={['shipping', 'address_1']} label="地址1" />
<ProFormText name={['shipping', 'address_2']} label="地址2" />
<ProFormText name={['shipping', 'city']} label="城市" />
<ProFormText name={['shipping', 'state']} label="省/州" />
<ProFormText name={['shipping', 'postcode']} label="邮编" />
<ProFormText name={['shipping', 'country']} label="国家" />
<ProFormText name={['shipping', 'phone']} label="电话" />
</ProForm.Group>
<Divider />
<ProFormTextArea name="customer_note" label="客户备注" />
<Divider />
<ProFormList
name="sales"
label="商品列表"
readonly
actionRender={() => []}
> >
{/* ... Fields ... */} <ProForm.Group>
<ProDescriptions.Item <ProFormText name="name" label="商品名" />
label="订单日期" <ProFormText name="sku" label="SKU" />
dataIndex="date_created" <ProFormDigit name="quantity" label="数量" />
valueType="dateTime" <ProFormText name="total" label="总价" />
/> </ProForm.Group>
{/* ... */} </ProFormList>
</ProDescriptions>
</Drawer> <ProFormText name="total" label="订单总额" readonly />
</>
</DrawerForm>
); );
}; };

View File

@ -0,0 +1,53 @@
{
admin_id: 0,
admin_name: "",
birthday: 0,
contact: "",
country_id: 14,
created_at: 1765351077,
domain: "auspouches.com",
email: "daniel.waring81@gmail.com",
first_name: "Dan",
first_pay_at: 1765351308,
gender: 0,
id: 44898147,
ip: "1.146.111.163",
is_cart: 0,
is_event_sub: 1,
is_sub: 1,
is_verified: 1,
last_name: "Waring",
last_order_id: 236122,
login_at: 1765351340,
note: "",
order_at: 1765351224,
orders_count: 1,
pay_at: 1765351308,
source_device: "phone",
tags: [
],
total_spent: "203.81",
updated_at: 1765351515,
utm_medium: "referral",
utm_source: "checkout.cartadicreditopay.com",
visit_at: 1765351513,
country: {
chinese_name: "澳大利亚",
country_code2: "AU",
country_name: "Australia",
},
sysinfo: {
user_agent: "Mozilla/5.0 (Linux; Android 16; Pixel 8 Pro Build/BP3A.251105.015; ) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/142.0.7444.212 Mobile Safari/537.36 MetaIAB Facebook",
timezone: "Etc/GMT-10",
os: "Android",
browser: "Pixel 8",
language: "en-GB",
screen_size: "528X1174",
viewport_size: "527X1026",
ip: "1.146.111.163",
},
default_address: [
],
addresses: [
],
}