feat: 添加产品工具, 重构产品 #31

Closed
zksu wants to merge 37 commits from (deleted):main into main
7 changed files with 436 additions and 109 deletions
Showing only changes of commit d40f157b78 - Show all commits

View File

@ -10,7 +10,7 @@ import {
ProTable,
} from '@ant-design/pro-components';
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';
// 区域数据项类型
@ -58,6 +58,62 @@ const SiteList: React.FC = () => {
const formRef = useRef<ProFormInstance>();
const [open, setOpen] = useState(false);
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(() => {
if (!open) return;
@ -174,6 +230,13 @@ const SiteList: React.FC = () => {
hideInSearch: true,
render: (_, row) => (
<Space>
<Button
size="small"
type="primary"
onClick={() => handleSync([row.id])}
>
</Button>
<Button
size="small"
onClick={() => {
@ -321,6 +384,10 @@ const SiteList: React.FC = () => {
rowKey="id"
columns={columns}
request={tableRequest}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
toolBarRender={() => [
<Button
key="new"
@ -333,10 +400,14 @@ const SiteList: React.FC = () => {
</Button>,
// 同步包括 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 { App, Avatar, Button, Popconfirm, Space, Tag } from 'antd';
import React, { useRef, useState } from 'react';
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 { message } = App.useApp();
const { siteId } = useParams<{ siteId: string }>();
@ -167,10 +218,11 @@ const CustomerPage: React.FC = () => {
<ProFormText name="last_name" label="姓" />
<ProFormText name="username" label="用户名" />
</DrawerForm>,
<Button
title="批量编辑"
icon={<EditOutlined />}
onClick={() => message.info('批量编辑暂未实现')}
<BatchEditCustomers
tableRef={actionRef}
selectedRowKeys={selectedRowKeys}
setSelectedRowKeys={setSelectedRowKeys}
siteId={siteId}
/>,
<Button
title="批量删除"

View File

@ -15,7 +15,7 @@ import {
ProTable,
} from '@ant-design/pro-components';
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 { ToastContainer } from 'react-toastify';
@ -198,16 +198,40 @@ const LogisticsPage: React.FC = () => {
success: false,
};
}}
// rowSelection={{
// selectedRowKeys: selectedRows.map((row) => row.id),
// onChange: (_, selectedRows) => setSelectedRows(selectedRows),
// }}
rowSelection={{
selectedRowKeys: selectedRows.map((row) => row.id),
onChange: (_, selectedRows) => setSelectedRows(selectedRows),
}}
columns={columns}
tableAlertOptionRender={() => {
return (
<Button onClick={handleBatchPrint} type="primary">
</Button>
<Space>
<Button onClick={handleBatchPrint} type="primary">
</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,
} from 'antd';
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';
const OrdersPage: React.FC = () => {
@ -122,9 +122,20 @@ const OrdersPage: React.FC = () => {
render: (_, record) => record.shipping?.phone || record.billing?.phone,
},
{
title: '州',
title: '账单地址',
dataIndex: 'billing_full_address',
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: '操作',
@ -135,7 +146,7 @@ const OrdersPage: React.FC = () => {
render: (_, record) => {
return (
<>
<Detail
<EditOrder
key={record.id}
record={record}
tableRef={actionRef}
@ -152,7 +163,7 @@ const OrdersPage: React.FC = () => {
key: 'history',
label: (
<HistoryOrder
email={record.email}
email={(record as any).email}
tableRef={actionRef}
/>
),
@ -165,10 +176,8 @@ const OrdersPage: React.FC = () => {
}}
>
<a onClick={(e) => e.preventDefault()}>
<Space>
<DownOutlined />
</Space>
<Button type="link" icon={<DownOutlined />}>
</Button>
</a>
</Dropdown>
<Divider type="vertical" />
@ -217,6 +226,12 @@ const OrdersPage: React.FC = () => {
}}
toolBarRender={() => [
<CreateOrder tableRef={actionRef} siteId={siteId} />,
<BatchEditOrders
tableRef={actionRef}
selectedRowKeys={selectedRowKeys}
setSelectedRowKeys={setSelectedRowKeys}
siteId={siteId}
/>,
<Button
title="批量删除"
danger

View File

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

View File

@ -15,11 +15,13 @@ import {
CodeSandboxOutlined,
CopyOutlined,
DeleteFilled,
EditOutlined,
FileDoneOutlined,
TagsOutlined,
} from '@ant-design/icons';
import {
ActionType,
DrawerForm,
ModalForm,
ProColumns,
ProDescriptions,
@ -78,7 +80,11 @@ export const OrderNote: React.FC<{
return (
<ModalForm
title="添加备注"
trigger={<Button type="primary" ghost title="备注" icon={<TagsOutlined />} />}
trigger={
<Button type="primary" ghost size="small" icon={<TagsOutlined />}>
</Button>
}
onFinish={async (values: any) => {
if (!siteId) {
message.error('缺少站点ID');
@ -236,12 +242,14 @@ export const Shipping: React.FC<{
trigger={
<Button
type="primary"
title="创建运单"
size="small"
icon={<CodeSandboxOutlined />}
onClick={() => {
setActiveLine(id);
}}
/>
>
</Button>
}
request={async () => {
if (!siteId) return {};
@ -569,7 +577,11 @@ export const CreateOrder: React.FC<{
body: { maxHeight: '65vh', overflowY: 'auto', overflowX: 'hidden' },
},
}}
trigger={<Button type="primary" title="创建订单" icon={<CodeSandboxOutlined />} />}
trigger={
<Button type="primary" size="small" icon={<CodeSandboxOutlined />}>
</Button>
}
params={{
source_type: 'admin',
}}
@ -618,94 +630,194 @@ 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>;
orderId: number;
record: API.Order;
setActiveLine: Function;
siteId?: string;
}> = ({ tableRef, orderId, record, setActiveLine, siteId }) => {
const [visiable, setVisiable] = useState(false);
const { message } = App.useApp();
const ref = useRef<ActionType>();
const initRequest = async () => {
if (!siteId) return { data: {} };
// Fetch detail from site-api
const { data, success } = await request(`/site-api/${siteId}/orders/${orderId}`);
if (!success || !data) return { data: {} };
// Merge sales logic
const sales = data.sales || [];
const mergedSales = sales.reduce(
(acc: any[], cur: any) => {
let idx = acc.findIndex((v: any) => v.productId === cur.productId);
if (idx === -1) {
acc.push(cur);
} else {
acc[idx].quantity += cur.quantity;
}
return acc;
},
[],
);
data.sales = mergedSales;
return {
data,
};
};
const formRef = useRef<ProFormInstance>();
return (
<>
<Button
key="detail"
type="primary"
title="详情"
icon={<FileDoneOutlined />}
onClick={() => {
setVisiable(true);
setActiveLine(record.id);
<DrawerForm
formRef={formRef}
title="编辑订单"
trigger={
<Button
type="primary"
size="small"
icon={<EditOutlined />}
onClick={() => setActiveLine(record.id)}
>
</Button>
}
drawerProps={{
destroyOnHidden: true,
width: '60vw',
}}
/>
<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
labelStyle={{ width: '100px' }}
actionRef={ref}
request={initRequest}
request={async () => {
if (!siteId) return {};
const { data, success } = await request(`/site-api/${siteId}/orders/${orderId}`);
if (!success || !data) return {};
const sales = data.sales || [];
const mergedSales = sales.reduce(
(acc: any[], cur: any) => {
let idx = acc.findIndex((v: any) => v.productId === cur.productId);
if (idx === -1) {
acc.push(cur);
} else {
acc[idx].quantity += cur.quantity;
}
return acc;
},
[],
);
data.sales = mergedSales;
return data;
}}
onFinish={async (values) => {
if (!siteId) return false;
try {
const res = await request(`/site-api/${siteId}/orders/${orderId}`, {
method: 'PUT',
data: values
});
if (res.success) {
message.success('更新成功');
tableRef.current?.reload();
return true;
}
message.error(res.message || '更新失败');
return false;
} catch(e: any) {
message.error(e.message || '更新失败');
return false;
}
}}
>
<ProForm.Group title="基本信息">
<ProFormText name="number" label="订单号" readonly />
<ProFormSelect name="status" label="状态" valueEnum={ORDER_STATUS_ENUM} />
<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 ... */}
<ProDescriptions.Item
label="订单日期"
dataIndex="date_created"
valueType="dateTime"
/>
{/* ... */}
</ProDescriptions>
</Drawer>
</>
<ProForm.Group>
<ProFormText name="name" label="商品名" />
<ProFormText name="sku" label="SKU" />
<ProFormDigit name="quantity" label="数量" />
<ProFormText name="total" label="总价" />
</ProForm.Group>
</ProFormList>
<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: [
],
}