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

Closed
zksu wants to merge 37 commits from (deleted):main into main
45 changed files with 34826 additions and 2645 deletions
Showing only changes of commit 54fa1b7ca2 - Show all commits

View File

@ -44,8 +44,7 @@ export default defineConfig({
],
},
{
{
name: '地区管理',
path: '/area',
access: 'canSeeArea',
@ -55,7 +54,7 @@ export default defineConfig({
path: '/area/list',
component: './Area/List',
},
{
{
name: '地区地图',
path: '/area/map',
component: './Area/Map',
@ -77,18 +76,31 @@ export default defineConfig({
path: '/site/shop',
component: './Site/Shop/Layout',
routes: [
{ path: '/site/shop/:siteId/products', component: './Site/Shop/Products' },
{ path: '/site/shop/:siteId/orders', component: './Site/Shop/Orders' },
{ path: '/site/shop/:siteId/subscriptions', component: './Site/Shop/Subscriptions' },
{ path: '/site/shop/:siteId/logistics', component: './Site/Shop/Logistics' },
{ path: '/site/shop/:siteId/media', component: './Site/Shop/Media' },
{ path: '/site/shop/:siteId/customers', component: './Site/Shop/Customers' },
{
path: '/site/shop/:siteId/products',
component: './Site/Shop/Products',
},
{
path: '/site/shop/:siteId/orders',
component: './Site/Shop/Orders',
},
{
path: '/site/shop/:siteId/subscriptions',
component: './Site/Shop/Subscriptions',
},
{
path: '/site/shop/:siteId/logistics',
component: './Site/Shop/Logistics',
},
{
path: '/site/shop/:siteId/media',
component: './Site/Shop/Media',
},
{
path: '/site/shop/:siteId/customers',
component: './Site/Shop/Customers',
},
],
},
{
name: 'Woo产品列表',
path: '/site/woocommerce/product/list',
component: './Woo/Product/List',
},
{
name: 'Woo标签工具',
@ -97,7 +109,7 @@ export default defineConfig({
},
],
},
{
{
name: '客户管理',
path: '/customer',
access: 'canSeeCustomer',
@ -125,7 +137,7 @@ export default defineConfig({
component: './Product/Permutation',
},
{
name: "产品分类",
name: '产品分类',
path: '/product/category',
component: './Product/Category',
},

File diff suppressed because one or more lines are too long

View File

@ -38,7 +38,7 @@ export default (initialState: any) => {
isSuper ||
isAdmin ||
(initialState?.user?.permissions?.includes('dict') ?? false);
const canSeeTemplate =
const canSeeTemplate =
isSuper ||
isAdmin ||
(initialState?.user?.permissions?.includes('template') ?? false);

View File

@ -1,6 +1,6 @@
// 运行时配置
import { GlobalOutlined, LogoutOutlined, UserOutlined } from '@ant-design/icons';
import { LogoutOutlined, UserOutlined } from '@ant-design/icons';
import {
ProLayoutProps,
ProSchemaValueEnumObj,

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import FingerprintJS from '@fingerprintjs/fingerprintjs';
import { useEffect, useState } from 'react';
/**
* Hook: 获取设备指纹(visitorId)

View File

@ -1,11 +1,10 @@
import {
ActionType,
DrawerForm,
ProColumns,
ProFormInstance,
ProFormSelect,
ProTable
ProTable,
} from '@ant-design/pro-components';
import { request } from '@umijs/max';
import { Button, message, Popconfirm, Space } from 'antd';
@ -181,7 +180,10 @@ const AreaList: React.FC = () => {
<ProFormSelect
name="code"
label="国家/地区"
options={countries.map(c => ({ label: `${c.name}(${c.code})`, value: c.code }))}
options={countries.map((c) => ({
label: `${c.name}(${c.code})`,
value: c.code,
}))}
placeholder="请选择国家/地区"
rules={[{ required: true, message: '国家/地区为必填项' }]}
showSearch

View File

@ -1,12 +1,12 @@
import React, { useEffect, useState } from 'react';
import ReactECharts from 'echarts-for-react';
import { request } from '@umijs/max';
import { Spin, message } from 'antd';
import * as echarts from 'echarts/core';
import ReactECharts from 'echarts-for-react';
import { MapChart } from 'echarts/charts';
import { TooltipComponent, VisualMapComponent } from 'echarts/components';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import * as countries from 'i18n-iso-countries';
import React, { useEffect, useState } from 'react';
// 注册 ECharts 组件
echarts.use([TooltipComponent, VisualMapComponent, MapChart, CanvasRenderer]);
@ -33,7 +33,7 @@ const AreaMap: React.FC = () => {
echarts.registerMap('world', worldMap);
// 2. 从后端获取已存储的区域列表
const areaResponse = await request('/area', {
const areaResponse = await request('/area', {
method: 'GET',
params: {
currentPage: 1,
@ -46,7 +46,7 @@ const AreaMap: React.FC = () => {
const savedAreas: AreaItem[] = areaResponse.data?.list || [];
// 3. 将后端数据转换为 ECharts 需要的格式
const mapData = savedAreas.map(area => {
const mapData = savedAreas.map((area) => {
let nameEn = countries.getName(area.code, 'en');
return {
name: nameEn || area.code,
@ -55,7 +55,6 @@ const AreaMap: React.FC = () => {
};
});
// 4. 配置 ECharts 地图选项
const mapOption = {
tooltip: {
@ -108,12 +107,20 @@ const AreaMap: React.FC = () => {
}, []);
if (loading) {
return <Spin size="large" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }} />;
return (
<Spin
size="large"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
}}
/>
);
}
return (
<ReactECharts
echarts={echarts}
option={option}

View File

@ -1,7 +1,14 @@
import { PlusOutlined } from '@ant-design/icons';
import {
PageContainer,
} from '@ant-design/pro-components';
productcontrollerCreatecategory,
productcontrollerCreatecategoryattribute,
productcontrollerDeletecategory,
productcontrollerDeletecategoryattribute,
productcontrollerGetcategoriesall,
productcontrollerGetcategoryattributes,
productcontrollerUpdatecategory,
} from '@/servers/api/product';
import { PlusOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { request } from '@umijs/max';
import {
Button,
@ -16,15 +23,6 @@ import {
message,
} from 'antd';
import React, { useEffect, useState } from 'react';
import {
productcontrollerCreatecategory,
productcontrollerCreatecategoryattribute,
productcontrollerDeletecategory,
productcontrollerDeletecategoryattribute,
productcontrollerGetcategoriesall,
productcontrollerGetcategoryattributes,
productcontrollerUpdatecategory,
} from '@/servers/api/product';
import { attributes } from '../Attribute/consts';
const { Sider, Content } = Layout;
@ -62,7 +60,9 @@ const CategoryPage: React.FC = () => {
const fetchCategoryAttributes = async (categoryId: number) => {
setLoadingAttributes(true);
try {
const res = await productcontrollerGetcategoryattributes({ categoryItemId: categoryId });
const res = await productcontrollerGetcategoryattributes({
categoryItemId: categoryId,
});
setCategoryAttributes(res || []);
} catch (error) {
message.error('获取分类属性失败');
@ -81,7 +81,10 @@ const CategoryPage: React.FC = () => {
const handleCategorySubmit = async (values: any) => {
try {
if (editingCategory) {
await productcontrollerUpdatecategory({ id: editingCategory.id }, values);
await productcontrollerUpdatecategory(
{ id: editingCategory.id },
values,
);
message.success('更新成功');
} else {
await productcontrollerCreatecategory(values);
@ -113,7 +116,9 @@ const CategoryPage: React.FC = () => {
const res = await request('/dict/list');
const filtered = (res || []).filter((d: any) => attributes.has(d.name));
// Filter out already added attributes
const existingDictIds = new Set(categoryAttributes.map((ca: any) => ca.dict.id));
const existingDictIds = new Set(
categoryAttributes.map((ca: any) => ca.dict.id),
);
const available = filtered.filter((d: any) => !existingDictIds.has(d.id));
setAvailableDicts(available);
setSelectedDictIds([]);
@ -154,8 +159,22 @@ const CategoryPage: React.FC = () => {
return (
<PageContainer>
<Layout style={{ background: '#fff', height: 'calc(100vh - 200px)' }}>
<Sider width={300} style={{ background: '#fff', borderRight: '1px solid #f0f0f0', padding: '16px' }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Sider
width={300}
style={{
background: '#fff',
borderRight: '1px solid #f0f0f0',
padding: '16px',
}}
>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontWeight: 'bold' }}></span>
<Button
type="primary"
@ -175,10 +194,15 @@ const CategoryPage: React.FC = () => {
dataSource={categories}
renderItem={(item) => (
<List.Item
className={selectedCategory?.id === item.id ? 'ant-list-item-active' : ''}
className={
selectedCategory?.id === item.id ? 'ant-list-item-active' : ''
}
style={{
cursor: 'pointer',
background: selectedCategory?.id === item.id ? '#e6f7ff' : 'transparent',
background:
selectedCategory?.id === item.id
? '#e6f7ff'
: 'transparent',
padding: '8px 12px',
borderRadius: '4px',
}}
@ -204,21 +228,30 @@ const CategoryPage: React.FC = () => {
}}
onCancel={(e) => e?.stopPropagation()}
>
<a onClick={(e) => e.stopPropagation()} style={{ color: 'red' }}></a>
<a
onClick={(e) => e.stopPropagation()}
style={{ color: 'red' }}
>
</a>
</Popconfirm>,
]}
>
<List.Item.Meta
title={item.title}
description={item.name}
/>
<List.Item.Meta title={item.title} description={item.name} />
</List.Item>
)}
/>
</Sider>
<Content style={{ padding: '24px' }}>
{selectedCategory ? (
<Card title={`分类:${selectedCategory.title} (${selectedCategory.name})`} extra={<Button type="primary" onClick={handleAddAttribute}></Button>}>
<Card
title={`分类:${selectedCategory.title} (${selectedCategory.name})`}
extra={
<Button type="primary" onClick={handleAddAttribute}>
</Button>
}
>
<List
loading={loadingAttributes}
dataSource={categoryAttributes}
@ -229,8 +262,10 @@ const CategoryPage: React.FC = () => {
title="确定移除该属性吗?"
onConfirm={() => handleDeleteAttribute(item.id)}
>
<Button type="link" danger></Button>
</Popconfirm>
<Button type="link" danger>
</Button>
</Popconfirm>,
]}
>
<List.Item.Meta
@ -242,7 +277,15 @@ const CategoryPage: React.FC = () => {
/>
</Card>
) : (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', color: '#999' }}>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
color: '#999',
}}
>
</div>
)}
@ -255,11 +298,19 @@ const CategoryPage: React.FC = () => {
onOk={() => categoryForm.submit()}
onCancel={() => setIsCategoryModalVisible(false)}
>
<Form form={categoryForm} onFinish={handleCategorySubmit} layout="vertical">
<Form
form={categoryForm}
onFinish={handleCategorySubmit}
layout="vertical"
>
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="name" label="标识 (Code)" rules={[{ required: true }]}>
<Form.Item
name="name"
label="标识 (Code)"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
</Form>
@ -279,7 +330,10 @@ const CategoryPage: React.FC = () => {
placeholder="请选择要关联的属性"
value={selectedDictIds}
onChange={setSelectedDictIds}
options={availableDicts.map(d => ({ label: d.title, value: d.id }))}
options={availableDicts.map((d) => ({
label: d.title,
value: d.id,
}))}
/>
</Form.Item>
</Form>

View File

@ -199,7 +199,7 @@ const ListPage: React.FC = () => {
return (
<PageContainer ghost>
<ProTable
scroll={{ x: 'max-content' }}
scroll={{ x: 'max-content' }}
headerTitle="查询表格"
actionRef={actionRef}
rowKey="id"

View File

@ -1,5 +1,9 @@
import { UploadOutlined } from '@ant-design/icons';
import { ActionType, PageContainer, ProTable } from '@ant-design/pro-components';
import {
ActionType,
PageContainer,
ProTable,
} from '@ant-design/pro-components';
import { request } from '@umijs/max';
import {
Button,
@ -98,14 +102,30 @@ const DictPage: React.FC = () => {
};
// 下载字典导入模板
const handleDownloadDictTemplate = () => {
// 创建一个空的 a 标签用于下载
const link = document.createElement('a');
link.href = '/dict/template'; // 指向后端的模板下载接口
link.setAttribute('download', 'dict_template.xlsx');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const handleDownloadDictTemplate = async () => {
try {
// 使用 request 函数获取带认证的文件数据
const response = await request('/dict/template', {
method: 'GET',
responseType: 'blob', // 指定响应类型为 blob
skipErrorHandler: true, // 跳过默认错误处理,自己处理错误
});
// 创建 blob 对象和下载链接
const blob = new Blob([response], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'dict_template.xlsx');
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (error: any) {
message.error('下载字典模板失败:' + (error.message || '未知错误'));
}
};
// 添加字典项
@ -153,14 +173,72 @@ const DictPage: React.FC = () => {
}
};
// 下载字典项导入模板
const handleDownloadDictItemTemplate = () => {
const link = document.createElement('a');
link.href = '/dict/item/template';
link.setAttribute('download', 'dict_item_template.xlsx');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// 导出字典项数据
const handleExportDictItems = async () => {
if (!selectedDict) {
message.warning('请先选择字典');
return;
}
try {
// 获取当前字典的所有数据
const response = await request('/dict/items', {
method: 'GET',
params: { dictId: selectedDict.id },
});
if (!response || response.length === 0) {
message.warning('当前字典没有数据可导出');
return;
}
// 将数据转换为CSV格式
const headers = [
'name',
'title',
'titleCN',
'value',
'sort',
'image',
'shortName',
];
const csvContent = [
headers.join(','),
...response.map((item: any) =>
headers
.map((header) => {
const value = item[header] || '';
// 如果值包含逗号或引号,需要转义
if (
typeof value === 'string' &&
(value.includes(',') || value.includes('"'))
) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
})
.join(','),
),
].join('\n');
// 创建blob并下载
const blob = new Blob(['\ufeff' + csvContent], {
// 添加BOM以支持中文
type: 'text/csv;charset=utf-8',
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `${selectedDict.name}_dict_items.csv`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
message.success(`成功导出 ${response.length} 条数据`);
} catch (error: any) {
message.error('导出字典项失败:' + (error.message || '未知错误'));
}
};
// Effects
@ -176,7 +254,11 @@ const DictPage: React.FC = () => {
key: 'action',
render: (_: any, record: any) => (
<Space size="small">
<Button type="link" size="small" onClick={() => handleEditDict(record)}>
<Button
type="link"
size="small"
onClick={() => handleEditDict(record)}
>
</Button>
<Button
@ -291,7 +373,7 @@ const DictPage: React.FC = () => {
</Button>
</Upload>
<Button size="small" onClick={handleDownloadDictTemplate}>
</Button>
</Space>
<Table
@ -359,11 +441,11 @@ const DictPage: React.FC = () => {
</Button>
</Upload>
<Button
onClick={handleDownloadDictItemTemplate}
onClick={handleExportDictItems}
disabled={!selectedDict}
size="small"
>
</Button>
</div>
<ProTable

View File

@ -119,7 +119,8 @@ const AttributePage: React.FC = () => {
dictId: selectedDict.id,
},
});
const exists = Array.isArray(list) && list.some((it: any) => it.id === itemId);
const exists =
Array.isArray(list) && list.some((it: any) => it.id === itemId);
if (exists) {
message.error('删除失败');
} else {
@ -147,7 +148,13 @@ const AttributePage: React.FC = () => {
{ title: '标题', dataIndex: 'title', key: 'title', copyable: true },
{ title: '中文标题', dataIndex: 'titleCN', key: 'titleCN', copyable: true },
{ title: '简称', dataIndex: 'shortName', key: 'shortName', copyable: true },
{ title: '图片', dataIndex: 'image', key: 'image', valueType: 'image', width: 80 },
{
title: '图片',
dataIndex: 'image',
key: 'image',
valueType: 'image',
width: 80,
},
{
title: '操作',
key: 'action',
@ -195,7 +202,13 @@ const AttributePage: React.FC = () => {
size="small"
/>
</Space>
<div style={{ marginTop: '8px', overflowY: 'auto', height: 'calc(100vh - 150px)' }}>
<div
style={{
marginTop: '8px',
overflowY: 'auto',
height: 'calc(100vh - 150px)',
}}
>
<Table
dataSource={dicts}
columns={dictColumns}

View File

@ -1,7 +1,14 @@
import { PlusOutlined } from '@ant-design/icons';
import {
PageContainer,
} from '@ant-design/pro-components';
productcontrollerCreatecategory,
productcontrollerCreatecategoryattribute,
productcontrollerDeletecategory,
productcontrollerDeletecategoryattribute,
productcontrollerGetcategoriesall,
productcontrollerGetcategoryattributes,
productcontrollerUpdatecategory,
} from '@/servers/api/product';
import { PlusOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { request } from '@umijs/max';
import {
Button,
@ -16,15 +23,6 @@ import {
message,
} from 'antd';
import React, { useEffect, useState } from 'react';
import {
productcontrollerCreatecategory,
productcontrollerCreatecategoryattribute,
productcontrollerDeletecategory,
productcontrollerDeletecategoryattribute,
productcontrollerGetcategoriesall,
productcontrollerGetcategoryattributes,
productcontrollerUpdatecategory,
} from '@/servers/api/product';
import { attributes } from '../Attribute/consts';
const { Sider, Content } = Layout;
@ -62,7 +60,9 @@ const CategoryPage: React.FC = () => {
const fetchCategoryAttributes = async (categoryId: number) => {
setLoadingAttributes(true);
try {
const res = await productcontrollerGetcategoryattributes({ id: categoryId });
const res = await productcontrollerGetcategoryattributes({
id: categoryId,
});
setCategoryAttributes(res?.data || []);
} catch (error) {
message.error('获取分类属性失败');
@ -81,7 +81,10 @@ const CategoryPage: React.FC = () => {
const handleCategorySubmit = async (values: any) => {
try {
if (editingCategory) {
await productcontrollerUpdatecategory({ id: editingCategory.id }, values);
await productcontrollerUpdatecategory(
{ id: editingCategory.id },
values,
);
message.success('更新成功');
} else {
await productcontrollerCreatecategory(values);
@ -112,10 +115,12 @@ const CategoryPage: React.FC = () => {
try {
const res = await request('/dict/list');
// Defensive check for response structure: handle both raw array and wrapped response
const list = Array.isArray(res) ? res : (res?.data || []);
const list = Array.isArray(res) ? res : res?.data || [];
const filtered = list.filter((d: any) => attributes.has(d.name));
// Filter out already added attributes
const existingDictIds = new Set(categoryAttributes.map((ca: any) => ca.dictId));
const existingDictIds = new Set(
categoryAttributes.map((ca: any) => ca.dictId),
);
const available = filtered.filter((d: any) => !existingDictIds.has(d.id));
setAvailableDicts(available);
setSelectedDictIds([]);
@ -132,12 +137,14 @@ const CategoryPage: React.FC = () => {
}
try {
// Loop through selected IDs and create attribute for each
await Promise.all(selectedDictIds.map(dictId =>
productcontrollerCreatecategoryattribute({
categoryId: selectedCategory.id,
dictId: dictId,
})
));
await Promise.all(
selectedDictIds.map((dictId) =>
productcontrollerCreatecategoryattribute({
categoryId: selectedCategory.id,
dictId: dictId,
}),
),
);
message.success('添加属性成功');
setIsAttributeModalVisible(false);
fetchCategoryAttributes(selectedCategory.id);
@ -159,8 +166,22 @@ const CategoryPage: React.FC = () => {
return (
<PageContainer>
<Layout style={{ background: '#fff', height: 'calc(100vh - 200px)' }}>
<Sider width={300} style={{ background: '#fff', borderRight: '1px solid #f0f0f0', padding: '16px' }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Sider
width={300}
style={{
background: '#fff',
borderRight: '1px solid #f0f0f0',
padding: '16px',
}}
>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontWeight: 'bold' }}></span>
<Button
type="primary"
@ -180,10 +201,15 @@ const CategoryPage: React.FC = () => {
dataSource={categories}
renderItem={(item) => (
<List.Item
className={selectedCategory?.id === item.id ? 'ant-list-item-active' : ''}
className={
selectedCategory?.id === item.id ? 'ant-list-item-active' : ''
}
style={{
cursor: 'pointer',
background: selectedCategory?.id === item.id ? '#e6f7ff' : 'transparent',
background:
selectedCategory?.id === item.id
? '#e6f7ff'
: 'transparent',
padding: '8px 12px',
borderRadius: '4px',
}}
@ -209,21 +235,30 @@ const CategoryPage: React.FC = () => {
}}
onCancel={(e) => e?.stopPropagation()}
>
<a onClick={(e) => e.stopPropagation()} style={{ color: 'red' }}></a>
<a
onClick={(e) => e.stopPropagation()}
style={{ color: 'red' }}
>
</a>
</Popconfirm>,
]}
>
<List.Item.Meta
title={item.title}
description={item.name}
/>
<List.Item.Meta title={item.title} description={item.name} />
</List.Item>
)}
/>
</Sider>
<Content style={{ padding: '24px' }}>
{selectedCategory ? (
<Card title={`分类:${selectedCategory.title} (${selectedCategory.name})`} extra={<Button type="primary" onClick={handleAddAttribute}></Button>}>
<Card
title={`分类:${selectedCategory.title} (${selectedCategory.name})`}
extra={
<Button type="primary" onClick={handleAddAttribute}>
</Button>
}
>
<List
loading={loadingAttributes}
dataSource={categoryAttributes}
@ -234,8 +269,10 @@ const CategoryPage: React.FC = () => {
title="确定移除该属性吗?"
onConfirm={() => handleDeleteAttribute(item.id)}
>
<Button type="link" danger></Button>
</Popconfirm>
<Button type="link" danger>
</Button>
</Popconfirm>,
]}
>
<List.Item.Meta
@ -247,7 +284,15 @@ const CategoryPage: React.FC = () => {
/>
</Card>
) : (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', color: '#999' }}>
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
color: '#999',
}}
>
</div>
)}
@ -260,11 +305,19 @@ const CategoryPage: React.FC = () => {
onOk={() => categoryForm.submit()}
onCancel={() => setIsCategoryModalVisible(false)}
>
<Form form={categoryForm} onFinish={handleCategorySubmit} layout="vertical">
<Form
form={categoryForm}
onFinish={handleCategorySubmit}
layout="vertical"
>
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="name" label="标识 (Code)" rules={[{ required: true }]}>
<Form.Item
name="name"
label="标识 (Code)"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
</Form>
@ -284,7 +337,10 @@ const CategoryPage: React.FC = () => {
placeholder="请选择要关联的属性"
value={selectedDictIds}
onChange={setSelectedDictIds}
options={availableDicts.map(d => ({ label: d.title, value: d.id }))}
options={availableDicts.map((d) => ({
label: d.title,
value: d.id,
}))}
/>
</Form.Item>
</Form>

View File

@ -1,3 +1,4 @@
import AttributeFormItem from '@/pages/Product/Attribute/components/AttributeFormItem';
import {
productcontrollerCreateproduct,
productcontrollerGetcategoriesall,
@ -18,7 +19,6 @@ import {
ProFormTextArea,
} from '@ant-design/pro-components';
import { App, Button, Tag } from 'antd';
import AttributeFormItem from '@/pages/Product/Attribute/components/AttributeFormItem';
import React, { useEffect, useRef, useState } from 'react';
const capitalize = (s: string) => s.charAt(0).toLocaleUpperCase() + s.slice(1);
@ -49,7 +49,9 @@ const CreateForm: React.FC<{
return;
}
try {
const res: any = await productcontrollerGetcategoryattributes({ id: categoryId });
const res: any = await productcontrollerGetcategoryattributes({
id: categoryId,
});
setActiveAttributes(res?.data || []);
} catch (error) {
message.error('获取分类属性失败');
@ -135,8 +137,8 @@ const CreateForm: React.FC<{
humidityName === 'dry'
? 'Dry'
: humidityName === 'moisture'
? 'Moisture'
: capitalize(humidityName),
? 'Moisture'
: capitalize(humidityName),
},
);
if (!success) {
@ -310,14 +312,16 @@ const CreateForm: React.FC<{
placeholder="请输入子产品SKU"
rules={[{ required: true, message: '请输入子产品SKU' }]}
request={async ({ keyWords }) => {
const params = keyWords ? { sku: keyWords, name: keyWords } : { pageSize: 9999 };
const params = keyWords
? { sku: keyWords, name: keyWords }
: { pageSize: 9999 };
const { data } = await getStocks(params as any);
if (!data || !data.items) {
return [];
}
return data.items
.filter(item => item.sku)
.map(item => ({
.filter((item) => item.sku)
.map((item) => ({
label: `${item.sku} - ${item.name}`,
value: item.sku,
}));
@ -341,7 +345,7 @@ const CreateForm: React.FC<{
name="categoryId"
label="分类"
width="md"
options={categories.map(c => ({ label: c.title, value: c.id }))}
options={categories.map((c) => ({ label: c.title, value: c.id }))}
placeholder="请选择分类"
rules={[{ required: true, message: '请选择分类' }]}
/>
@ -372,17 +376,16 @@ const CreateForm: React.FC<{
/>
<ProFormTextArea
name="shortDescription"
style={{width: '100%'}}
style={{ width: '100%' }}
label="产品简短描述"
placeholder="请输入产品简短描述"
/>
<ProFormTextArea
name="description"
style={{width: '100%'}}
name="description"
style={{ width: '100%' }}
label="产品描述"
placeholder="请输入产品描述"
/>
</DrawerForm>
);
};

View File

@ -1,3 +1,4 @@
import AttributeFormItem from '@/pages/Product/Attribute/components/AttributeFormItem';
import {
productcontrollerGetcategoriesall,
productcontrollerGetcategoryattributes,
@ -18,7 +19,6 @@ import {
ProFormTextArea,
} from '@ant-design/pro-components';
import { App, Button, Tag } from 'antd';
import AttributeFormItem from '@/pages/Product/Attribute/components/AttributeFormItem';
import React, { useEffect, useMemo, useRef, useState } from 'react';
const EditForm: React.FC<{
@ -35,7 +35,7 @@ const EditForm: React.FC<{
const [stockStatus, setStockStatus] = useState<
'in-stock' | 'out-of-stock' | null
>(null);
const [siteSkuEntries, setSiteSkuEntries] = useState<any[]>([]);
const [siteSkuCodes, setSiteSkuCodes] = useState<string[]>([]);
const [sites, setSites] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
@ -52,11 +52,14 @@ const EditForm: React.FC<{
}, []);
useEffect(() => {
const categoryId = (record as any).categoryId || (record as any).category?.id;
const categoryId =
(record as any).categoryId || (record as any).category?.id;
if (categoryId) {
productcontrollerGetcategoryattributes({ id: categoryId }).then((res: any) => {
setActiveAttributes(res?.data || []);
});
productcontrollerGetcategoryattributes({ id: categoryId }).then(
(res: any) => {
setActiveAttributes(res?.data || []);
},
);
} else {
setActiveAttributes([]);
}
@ -68,7 +71,9 @@ const EditForm: React.FC<{
return;
}
try {
const res: any = await productcontrollerGetcategoryattributes({ id: categoryId });
const res: any = await productcontrollerGetcategoryattributes({
id: categoryId,
});
setActiveAttributes(res?.data || []);
} catch (error) {
message.error('获取分类属性失败');
@ -95,8 +100,14 @@ const EditForm: React.FC<{
await productcontrollerGetproductcomponents({ id: record.id });
setComponents(componentsData || []);
// 获取站点SKU详细信息
const { data: siteSkusData } = await productcontrollerGetproductsiteskus({ id: record.id });
setSiteSkuEntries(siteSkusData || []);
const { data: siteSkusData } = await productcontrollerGetproductsiteskus({
id: record.id,
});
// 只提取code字段组成字符串数组
const codes = siteSkusData
? siteSkusData.map((item: any) => item.code)
: [];
setSiteSkuCodes(codes);
})();
}, [record]);
@ -117,10 +128,10 @@ const EditForm: React.FC<{
components: components,
type: type,
categoryId: (record as any).categoryId || (record as any).category?.id,
// 初始化站点SKU列表为对象形式
siteSkus: siteSkuEntries && siteSkuEntries.length ? siteSkuEntries : [],
// 初始化站点SKU为字符串数组
siteSkus: siteSkuCodes,
};
}, [record, components, type, siteSkuEntries]);
}, [record, components, type, siteSkuCodes]);
return (
<DrawerForm<any>
@ -186,12 +197,15 @@ const EditForm: React.FC<{
attributes,
type: values.type, // 直接使用 type
categoryId: values.categoryId,
siteSkus: values.siteSkus,
siteSkus: values.siteSkus || [], // 直接传递字符串数组
// 连带更新 components
components: values.type === 'bundle' ? (values.components || []).map((c: any) => ({
sku: c.sku,
quantity: Number(c.quantity),
})) : [],
components:
values.type === 'bundle'
? (values.components || []).map((c: any) => ({
sku: c.sku,
quantity: Number(c.quantity),
}))
: [],
};
const { success, message: errMsg } =
@ -223,43 +237,35 @@ const EditForm: React.FC<{
</Tag>
)}
</ProForm.Group>
<ProFormList
name="siteSkus"
label="站点SKU"
creatorButtonProps={{ position: 'bottom', creatorButtonText: '新增站点SKU' }}
itemRender={({ listDom, action }) => (
<div style={{ marginBottom: 8, display: 'flex', flexDirection: 'row', alignItems: 'end' }}>
{listDom}
{action}
</div>
)}
>
<ProForm.Group>
<ProFormSelect
name="siteId"
label="站点"
width="md"
options={sites.map((site) => ({ label: site.name, value: site.id }))}
placeholder="请选择站点"
rules={[{ required: true, message: '请选择站点' }]}
/>
<ProFormText
name="code"
label="站点SKU"
width="md"
placeholder="请输入站点SKU"
rules={[{ required: true, message: '请输入站点SKU' }]}
/>
<ProFormText
name="quantity"
label="数量"
width="md"
placeholder="请输入数量"
/>
</ProForm.Group>
</ProFormList>
<ProFormList
name="siteSkus"
label="站点SKU"
creatorButtonProps={{
position: 'bottom',
creatorButtonText: '新增站点SKU',
}}
itemRender={({ listDom, action }) => (
<div
style={{
marginBottom: 8,
display: 'flex',
flexDirection: 'row',
alignItems: 'end',
}}
>
{listDom}
{action}
</div>
)}
>
<ProFormText
name="code"
width="md"
placeholder="请输入站点SKU"
rules={[{ required: true, message: '请输入站点SKU' }]}
/>
</ProFormList>
<ProForm.Group>
<ProFormText
name="name"
label="名称"
@ -316,14 +322,16 @@ const EditForm: React.FC<{
placeholder="请输入库存SKU"
rules={[{ required: true, message: '请输入库存SKU' }]}
request={async ({ keyWords }) => {
const params = keyWords ? { sku: keyWords, name: keyWords } : { pageSize: 9999 };
const params = keyWords
? { sku: keyWords, name: keyWords }
: { pageSize: 9999 };
const { data } = await getStocks(params as any);
if (!data || !data.items) {
return [];
}
return data.items
.filter(item => item.sku)
.map(item => ({
.filter((item) => item.sku)
.map((item) => ({
label: `${item.sku} - ${item.name}`,
value: item.sku,
}));
@ -362,7 +370,7 @@ const EditForm: React.FC<{
name="categoryId"
label="分类"
width="md"
options={categories.map(c => ({ label: c.title, value: c.id }))}
options={categories.map((c) => ({ label: c.title, value: c.id }))}
placeholder="请选择分类"
rules={[{ required: true, message: '请选择分类' }]}
/>
@ -388,7 +396,6 @@ const EditForm: React.FC<{
label="产品描述"
placeholder="请输入产品描述"
/>
</DrawerForm>
);
};

View File

@ -1,21 +1,28 @@
import {
productcontrollerBatchupdateproduct,
productcontrollerDeleteproduct,
productcontrollerBatchdeleteproduct,
productcontrollerBatchupdateproduct,
productcontrollerBindproductsiteskus,
productcontrollerDeleteproduct,
productcontrollerGetcategoriesall,
productcontrollerGetproductcomponents,
productcontrollerGetproductlist,
productcontrollerUpdatenamecn,
productcontrollerBindproductsiteskus,
} from '@/servers/api/product';
import { sitecontrollerAll } from '@/servers/api/site';
import { siteapicontrollerGetproducts } from '@/servers/api/siteApi';
import {
wpproductcontrollerBatchsynctosite,
wpproductcontrollerGetwpproducts,
wpproductcontrollerSynctoproduct,
} from '@/servers/api/wpProduct';
import { siteapicontrollerGetproducts } from '@/servers/api/siteApi';
import { ActionType, ModalForm, PageContainer, ProColumns, ProFormSelect, ProFormText, ProTable } from '@ant-design/pro-components';
import {
ActionType,
ModalForm,
PageContainer,
ProColumns,
ProFormSelect,
ProFormText,
ProTable,
} from '@ant-design/pro-components';
import { request } from '@umijs/max';
import { App, Button, Modal, Popconfirm, Tag, Upload } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
@ -125,15 +132,17 @@ const BatchEditModal: React.FC<{
const updateData: any = { ids };
// 只有当用户输入了值才进行更新
if (values.price) updateData.price = Number(values.price);
if (values.promotionPrice) updateData.promotionPrice = Number(values.promotionPrice);
if (values.promotionPrice)
updateData.promotionPrice = Number(values.promotionPrice);
if (values.categoryId) updateData.categoryId = values.categoryId;
if (Object.keys(updateData).length <= 1) {
message.warning('未修改任何属性');
return false;
message.warning('未修改任何属性');
return false;
}
const { success, message: errMsg } = await productcontrollerBatchupdateproduct(updateData);
const { success, message: errMsg } =
await productcontrollerBatchupdateproduct(updateData);
if (success) {
message.success('批量修改成功');
onSuccess();
@ -145,11 +154,7 @@ const BatchEditModal: React.FC<{
}
}}
>
<ProFormText
name="price"
label="价格"
placeholder="不修改请留空"
/>
<ProFormText name="price" label="价格" placeholder="不修改请留空" />
<ProFormText
name="promotionPrice"
label="促销价格"
@ -158,7 +163,7 @@ const BatchEditModal: React.FC<{
<ProFormSelect
name="categoryId"
label="分类"
options={categories.map(c => ({ label: c.title, value: c.id }))}
options={categories.map((c) => ({ label: c.title, value: c.id }))}
placeholder="不修改请留空"
/>
</ModalForm>
@ -211,7 +216,7 @@ const SyncToSiteModal: React.FC<{
try {
await wpproductcontrollerBatchsynctosite(
{ siteId: values.siteId },
{ productIds }
{ productIds },
);
const map = values.productSiteSkus || {};
for (const currentProductId of productIds) {
@ -219,7 +224,15 @@ const SyncToSiteModal: React.FC<{
if (entry && entry.code) {
await productcontrollerBindproductsiteskus(
{ id: currentProductId },
{ siteSkus: [{ siteId: values.siteId, code: entry.code, quantity: entry.quantity }] }
{
siteSkus: [
{
siteId: values.siteId,
code: entry.code,
quantity: entry.quantity,
},
],
},
);
}
}
@ -239,7 +252,10 @@ const SyncToSiteModal: React.FC<{
rules={[{ required: true, message: '请选择站点' }]}
/>
{productRows.map((row) => (
<div key={row.id} style={{ display: 'flex', gap: 12, alignItems: 'flex-end' }}>
<div
key={row.id}
style={{ display: 'flex', gap: 12, alignItems: 'flex-end' }}
>
<div style={{ minWidth: 220 }}>SKU: {row.sku || '-'}</div>
<ProFormText
name={['productSiteSkus', row.id, 'code']}
@ -257,11 +273,11 @@ const SyncToSiteModal: React.FC<{
);
};
const WpProductInfo: React.FC<{ skus: string[]; record: API.Product; parentTableRef: React.MutableRefObject<ActionType | undefined> }> = ({
skus,
record,
parentTableRef,
}) => {
const WpProductInfo: React.FC<{
skus: string[];
record: API.Product;
parentTableRef: React.MutableRefObject<ActionType | undefined>;
}> = ({ skus, record, parentTableRef }) => {
const actionRef = useRef<ActionType>();
const { message } = App.useApp();
@ -295,12 +311,13 @@ const WpProductInfo: React.FC<{ skus: string[]; record: API.Product; parentTable
// 遍历每一个SKU在当前站点进行搜索
for (const skuCode of skus) {
// 直接调用站点API根据搜索关键字获取产品列表
const { data: productPage } = await siteapicontrollerGetproducts({
siteId: siteItem.id,
const response = await siteapicontrollerGetproducts({
siteId: Number(siteItem.id),
per_page: 100,
search: skuCode,
});
const siteProducts = productPage?.items || [];
const productPage = response as any;
const siteProducts = productPage?.data?.items || [];
// 将站点信息附加到产品数据中便于展示
siteProducts.forEach((p: any) => {
aggregatedProducts.push({
@ -382,7 +399,9 @@ const WpProductInfo: React.FC<{ skus: string[]; record: API.Product; parentTable
description="确认删除?"
onConfirm={async () => {
try {
await request(`/wp_product/${wpRow.id}`, { method: 'DELETE' });
await request(`/wp_product/${wpRow.id}`, {
method: 'DELETE',
});
message.success('删除成功');
actionRef.current?.reload();
} catch (e: any) {
@ -443,9 +462,9 @@ const List: React.FC = () => {
dataIndex: 'siteSkus',
render: (_, record) => (
<>
{record.siteSkus?.map((item, index) => (
{record.siteSkus?.map((code, index) => (
<Tag key={index} color="cyan">
{item.code}
{code}
</Tag>
))}
</>
@ -471,7 +490,7 @@ const List: React.FC = () => {
dataIndex: 'category',
render: (_, record: any) => {
return record.category?.title || record.category?.name || '-';
}
},
},
{
title: '价格',
@ -584,7 +603,6 @@ const List: React.FC = () => {
<PageContainer header={{ title: '产品列表' }}>
<ProTable<API.Product>
scroll={{ x: 'max-content' }}
headerTitle="查询表格"
actionRef={actionRef}
rowKey="id"
@ -592,8 +610,12 @@ const List: React.FC = () => {
// 新建按钮
<CreateForm tableRef={actionRef} />,
// 批量编辑按钮
<Button disabled={selectedRows.length <= 0} onClick={() => setBatchEditModalVisible(true)}></Button>
,
<Button
disabled={selectedRows.length <= 0}
onClick={() => setBatchEditModalVisible(true)}
>
</Button>,
// 批量同步按钮
<Button
disabled={selectedRows.length <= 0}
@ -614,9 +636,10 @@ const List: React.FC = () => {
content: `确定要删除选中的 ${selectedRows.length} 个产品吗?此操作不可恢复。`,
onOk: async () => {
try {
const { success, message: errMsg } = await productcontrollerBatchdeleteproduct({
ids: selectedRows.map((row) => row.id),
});
const { success, message: errMsg } =
await productcontrollerBatchdeleteproduct({
ids: selectedRows.map((row) => row.id),
});
if (success) {
message.success('批量删除成功');
setSelectedRows([]);
@ -651,9 +674,13 @@ const List: React.FC = () => {
data: formData,
requestType: 'form',
});
const { created = 0, updated = 0, errors = [] } = res.data || {};
const {
created = 0,
updated = 0,
errors = [],
} = res.data || {};
if (errors && errors.length > 0) {
Modal.warning({
title: '导入结果 (存在错误)',
@ -662,18 +689,31 @@ const List: React.FC = () => {
<div>
<p>: {created}</p>
<p>: {updated}</p>
<p style={{ color: 'red', fontWeight: 'bold' }}>: {errors.length}</p>
<div style={{
maxHeight: '300px',
overflowY: 'auto',
background: '#f5f5f5',
padding: '8px',
marginTop: '8px',
borderRadius: '4px',
border: '1px solid #d9d9d9'
}}>
<p style={{ color: 'red', fontWeight: 'bold' }}>
: {errors.length}
</p>
<div
style={{
maxHeight: '300px',
overflowY: 'auto',
background: '#f5f5f5',
padding: '8px',
marginTop: '8px',
borderRadius: '4px',
border: '1px solid #d9d9d9',
}}
>
{errors.map((err: string, idx: number) => (
<div key={idx} style={{ fontSize: '12px', marginBottom: '4px', borderBottom: '1px solid #e8e8e8', paddingBottom: '2px', color: '#ff4d4f' }}>
<div
key={idx}
style={{
fontSize: '12px',
marginBottom: '4px',
borderBottom: '1px solid #e8e8e8',
paddingBottom: '2px',
color: '#ff4d4f',
}}
>
{idx + 1}. {err}
</div>
))}
@ -721,7 +761,7 @@ const List: React.FC = () => {
expandable={{
expandedRowRender: (record) => (
<WpProductInfo
skus={record.siteSkus?.map((s) => s.code) || []}
skus={(record.siteSkus as string[]) || []}
record={record}
parentTableRef={actionRef}
/>

View File

@ -28,13 +28,13 @@ const CreateModal: React.FC<CreateModalProps> = ({
// Helper to generate default name based on attributes
const generateDefaultName = () => {
if (!category) return '';
const parts = [category.name];
attributes.forEach(attr => {
const val = permutation[attr.name];
if (val) parts.push(val.name);
});
return parts.join(' - ');
if (!category) return '';
const parts = [category.name];
attributes.forEach((attr) => {
const val = permutation[attr.name];
if (val) parts.push(val.name);
});
return parts.join(' - ');
};
useEffect(() => {
@ -57,10 +57,11 @@ const CreateModal: React.FC<CreateModalProps> = ({
humidity: humidity ? capitalize(humidity) : '',
};
const { success, data: rendered } = await templatecontrollerRendertemplate(
{ name: 'product.sku' },
variables
);
const { success, data: rendered } =
await templatecontrollerRendertemplate(
{ name: 'product.sku' },
variables,
);
if (success && rendered) {
form.setFieldValue('sku', rendered);
@ -69,7 +70,7 @@ const CreateModal: React.FC<CreateModalProps> = ({
console.error('Failed to generate SKU', error);
}
};
generateSku();
form.setFieldValue('name', generateDefaultName());
}
@ -90,8 +91,8 @@ const CreateModal: React.FC<CreateModalProps> = ({
// Construct attributes payload
// Expected format: [{ dictName: 'Size', name: 'S' }, ...]
const payloadAttributes = attributes
.filter(attr => permutation[attr.name])
.map(attr => ({
.filter((attr) => permutation[attr.name])
.map((attr) => ({
dictName: attr.name,
name: permutation[attr.name].name,
}));
@ -105,7 +106,8 @@ const CreateModal: React.FC<CreateModalProps> = ({
};
try {
const { success, message: errMsg } = await productcontrollerCreateproduct(payload as any);
const { success, message: errMsg } =
await productcontrollerCreateproduct(payload as any);
if (success) {
message.success('产品创建成功');
onSuccess();
@ -120,12 +122,15 @@ const CreateModal: React.FC<CreateModalProps> = ({
}
}}
>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="分类">
{category?.name}
</Descriptions.Item>
<Descriptions
column={1}
bordered
size="small"
style={{ marginBottom: 24 }}
>
<Descriptions.Item label="分类">{category?.name}</Descriptions.Item>
<Descriptions.Item label="属性">
{attributes.map(attr => {
{attributes.map((attr) => {
const val = permutation[attr.name];
if (!val) return null;
return (

View File

@ -1,33 +1,35 @@
import {
productcontrollerCreateproduct,
productcontrollerGetcategoriesall,
productcontrollerGetcategoryattributes,
productcontrollerGetproductlist,
} from '@/servers/api/product';
import { request } from '@umijs/max';
import {
ActionType,
PageContainer,
ProCard,
ProColumns,
ProForm,
ProFormSelect,
ProTable,
} from '@ant-design/pro-components';
import { Button, Table, Tag, message } from 'antd';
import React, { useEffect, useState, useRef } from 'react';
import CreateModal from './components/CreateModal';
import { request } from '@umijs/max';
import { Button, Tag, message } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import EditForm from '../List/EditForm';
import CreateModal from './components/CreateModal';
const PermutationPage: React.FC = () => {
const [categoryId, setCategoryId] = useState<number>();
const [attributes, setAttributes] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [attributeValues, setAttributeValues] = useState<Record<string, any[]>>({});
const [attributeValues, setAttributeValues] = useState<Record<string, any[]>>(
{},
);
const [permutations, setPermutations] = useState<any[]>([]);
const [existingProducts, setExistingProducts] = useState<Map<string, API.Product>>(new Map());
const [existingProducts, setExistingProducts] = useState<
Map<string, API.Product>
>(new Map());
const [productsLoading, setProductsLoading] = useState(false);
const [createModalVisible, setCreateModalVisible] = useState(false);
const [selectedPermutation, setSelectedPermutation] = useState<any>(null);
const [categories, setCategories] = useState<any[]>([]);
@ -38,7 +40,7 @@ const PermutationPage: React.FC = () => {
useEffect(() => {
productcontrollerGetcategoriesall().then((res) => {
const list = Array.isArray(res) ? res : (res?.data || []);
const list = Array.isArray(res) ? res : res?.data || [];
setCategories(list);
if (list.length > 0) {
setCategoryId(list[0].id);
@ -50,18 +52,18 @@ const PermutationPage: React.FC = () => {
const fetchProducts = async (catId: number) => {
setProductsLoading(true);
try {
const productRes = await productcontrollerGetproductlist({
categoryId: catId,
pageSize: 2000,
current: 1
const productRes = await productcontrollerGetproductlist({
categoryId: catId,
pageSize: 2000,
current: 1,
});
const products = productRes.data?.items || [];
const productMap = new Map<string, API.Product>();
products.forEach((p: any) => {
if (p.attributes && Array.isArray(p.attributes)) {
const key = generateAttributeKey(p.attributes);
if (key) productMap.set(key, p);
const key = generateAttributeKey(p.attributes);
if (key) productMap.set(key, p);
}
});
setExistingProducts(productMap);
@ -101,8 +103,10 @@ const PermutationPage: React.FC = () => {
setLoading(true);
try {
// 1. Fetch Attributes
const attrRes = await productcontrollerGetcategoryattributes({ id: categoryId });
const attrs = Array.isArray(attrRes) ? attrRes : (attrRes?.data || []);
const attrRes = await productcontrollerGetcategoryattributes({
id: categoryId,
});
const attrs = Array.isArray(attrRes) ? attrRes : attrRes?.data || [];
setAttributes(attrs);
// 2. Fetch Attribute Values (Dict Items)
@ -110,15 +114,16 @@ const PermutationPage: React.FC = () => {
for (const attr of attrs) {
const dictId = attr.dict?.id || attr.dictId;
if (dictId) {
const itemsRes = await request('/dict/items', { params: { dictId } });
valuesMap[attr.name] = itemsRes || [];
const itemsRes = await request('/dict/items', {
params: { dictId },
});
valuesMap[attr.name] = itemsRes || [];
}
}
setAttributeValues(valuesMap);
// 3. Fetch Existing Products
await fetchProducts(categoryId);
} catch (error) {
console.error(error);
message.error('获取数据失败');
@ -137,11 +142,13 @@ const PermutationPage: React.FC = () => {
return;
}
const validAttributes = attributes.filter(attr => attributeValues[attr.name]?.length > 0);
const validAttributes = attributes.filter(
(attr) => attributeValues[attr.name]?.length > 0,
);
if (validAttributes.length === 0) {
setPermutations([]);
return;
setPermutations([]);
return;
}
const generateCombinations = (index: number, current: any): any[] => {
@ -154,41 +161,42 @@ const PermutationPage: React.FC = () => {
let res: any[] = [];
for (const val of values) {
res = res.concat(generateCombinations(index + 1, { ...current, [attr.name]: val }));
res = res.concat(
generateCombinations(index + 1, { ...current, [attr.name]: val }),
);
}
return res;
};
const combos = generateCombinations(0, {});
setPermutations(combos);
}, [attributes, attributeValues]);
const generateAttributeKey = (attrs: any[]) => {
const parts = attrs.map(a => {
const key = a.dict?.name || a.dictName;
const val = a.name || a.value;
return `${key}:${val}`;
const parts = attrs.map((a) => {
const key = a.dict?.name || a.dictName;
const val = a.name || a.value;
return `${key}:${val}`;
});
return parts.sort().join('|');
};
const generateKeyFromPermutation = (perm: any) => {
const parts = Object.keys(perm).map(attrName => {
const valItem = perm[attrName];
const val = valItem.name;
return `${attrName}:${val}`;
});
return parts.sort().join('|');
const parts = Object.keys(perm).map((attrName) => {
const valItem = perm[attrName];
const val = valItem.name;
return `${attrName}:${val}`;
});
return parts.sort().join('|');
};
const handleAdd = (record: any) => {
setSelectedPermutation(record);
setCreateModalVisible(true);
setSelectedPermutation(record);
setCreateModalVisible(true);
};
const columns: any[] = [
...attributes.map(attr => ({
...attributes.map((attr) => ({
title: attr.title || attr.name,
dataIndex: attr.name,
width: 100, // Make columns narrower
@ -198,7 +206,10 @@ const PermutationPage: React.FC = () => {
const valB = b[attr.name]?.name || '';
return valA.localeCompare(valB);
},
filters: attributeValues[attr.name]?.map((v: any) => ({ text: v.name, value: v.name })),
filters: attributeValues[attr.name]?.map((v: any) => ({
text: v.name,
value: v.name,
})),
onFilter: (value: any, record: any) => record[attr.name]?.name === value,
})),
{
@ -241,15 +252,19 @@ const PermutationPage: React.FC = () => {
const key = generateKeyFromPermutation(record);
const product = existingProducts.get(key);
if (product) {
return (
<EditForm
record={product}
tableRef={actionRef}
trigger={<Button type="link" size="small"></Button>}
/>
);
return (
<EditForm
record={product}
tableRef={actionRef}
trigger={
<Button type="link" size="small">
</Button>
}
/>
);
}
return (
<Button type="primary" size="small" onClick={() => handleAdd(record)}>
@ -273,8 +288,8 @@ const PermutationPage: React.FC = () => {
label="选择分类"
width="md"
options={categories.map((item: any) => ({
label: item.name,
value: item.id,
label: item.name,
value: item.id,
}))}
fieldProps={{
onChange: (val) => setCategoryId(val as number),
@ -283,35 +298,35 @@ const PermutationPage: React.FC = () => {
</ProForm>
{categoryId && (
<ProTable
size="small"
dataSource={permutations}
columns={columns}
loading={loading || productsLoading}
rowKey={(record) => generateKeyFromPermutation(record)}
pagination={{
defaultPageSize: 50,
showSizeChanger: true,
pageSizeOptions: ['50', '100', '200', '500', '1000', '2000']
}}
scroll={{ x: 'max-content' }}
search={false}
toolBarRender={false}
/>
<ProTable
size="small"
dataSource={permutations}
columns={columns}
loading={loading || productsLoading}
rowKey={(record) => generateKeyFromPermutation(record)}
pagination={{
defaultPageSize: 50,
showSizeChanger: true,
pageSizeOptions: ['50', '100', '200', '500', '1000', '2000'],
}}
scroll={{ x: 'max-content' }}
search={false}
toolBarRender={false}
/>
)}
</ProCard>
{selectedPermutation && (
<CreateModal
visible={createModalVisible}
onClose={() => setCreateModalVisible(false)}
onSuccess={() => {
setCreateModalVisible(false);
if (categoryId) fetchProducts(categoryId);
}}
category={categories.find(c => c.id === categoryId) || null}
permutation={selectedPermutation}
attributes={attributes}
visible={createModalVisible}
onClose={() => setCreateModalVisible(false)}
onSuccess={() => {
setCreateModalVisible(false);
if (categoryId) fetchProducts(categoryId);
}}
category={categories.find((c) => c.id === categoryId) || null}
permutation={selectedPermutation}
attributes={attributes}
/>
)}
</PageContainer>

View File

@ -1,10 +1,15 @@
import { ModalForm, ProFormText } from '@ant-design/pro-components';
import { productcontrollerGetproductlist } from '@/servers/api/product';
import { templatecontrollerGettemplatebyname } from '@/servers/api/template';
import { EditOutlined, SyncOutlined } from '@ant-design/icons';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import {
ActionType,
ModalForm,
ProColumns,
ProFormText,
ProTable,
} from '@ant-design/pro-components';
import { request } from '@umijs/max';
import { Card, Spin, Tag, message, Button } from 'antd';
import { Button, Card, Spin, Tag, message, Select, Progress, Modal } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import EditForm from '../List/EditForm';
@ -36,6 +41,10 @@ interface WpProduct {
interface ProductWithWP extends API.Product {
wpProducts: Record<string, WpProduct>;
attributes?: any[];
siteSkus?: Array<{
siteSku: string;
[key: string]: any;
}>;
}
// 定义API响应接口
@ -76,6 +85,11 @@ const ProductSyncPage: React.FC = () => {
const [skuTemplate, setSkuTemplate] = useState<string>('');
const [initialLoading, setInitialLoading] = useState(true);
const actionRef = useRef<ActionType>();
const [selectedSiteId, setSelectedSiteId] = useState<string>('');
const [batchSyncModalVisible, setBatchSyncModalVisible] = useState(false);
const [syncProgress, setSyncProgress] = useState(0);
const [syncing, setSyncing] = useState(false);
const [syncResults, setSyncResults] = useState<{ success: number; failed: number; errors: string[] }>({ success: 0, failed: 0, errors: [] });
// 初始化数据:获取站点和所有 WP 产品
useEffect(() => {
@ -92,26 +106,27 @@ const ProductSyncPage: React.FC = () => {
// 获取所有 WordPress 商品
const wpProductsResponse = await getWPProducts();
const wpProductList: WpProduct[] = wpProductsResponse.data || [];
// 构建 WP 产品 MapKey 为 SKU
const map = new Map<string, WpProduct>();
wpProductList.forEach((p) => {
if (p.sku) {
map.set(p.sku, p);
}
if (p.sku) {
map.set(p.sku, p);
}
});
setWpProductMap(map);
// 获取 SKU 模板
try {
const templateRes = await templatecontrollerGettemplatebyname({ name: 'site.product.sku' });
const templateRes = await templatecontrollerGettemplatebyname({
name: 'site.product.sku',
});
if (templateRes && templateRes.value) {
setSkuTemplate(templateRes.value);
}
} catch (e) {
console.log('Template site.product.sku not found, using default.');
}
} catch (error) {
message.error('获取基础数据失败,请重试');
console.error('Error fetching data:', error);
@ -124,7 +139,12 @@ const ProductSyncPage: React.FC = () => {
}, []);
// 同步产品到站点
const syncProductToSite = async (values: any, record: ProductWithWP, site: Site, wpProductId?: string) => {
const syncProductToSite = async (
values: any,
record: ProductWithWP,
site: Site,
wpProductId?: string,
) => {
try {
const hide = message.loading('正在同步...', 0);
const data = {
@ -141,14 +161,14 @@ const ProductSyncPage: React.FC = () => {
let res;
if (wpProductId) {
res = await request(`/wp_product/siteId/${site.id}/products/${wpProductId}`, {
method: 'PUT',
data,
res = await request(`/site-api/${site.id}/products/${wpProductId}`, {
method: 'PUT',
data,
});
} else {
res = await request(`/wp_product/siteId/${site.id}/products`, {
method: 'POST',
data,
res = await request(`/site-api/${site.id}/products`, {
method: 'POST',
data,
});
}
@ -156,16 +176,15 @@ const ProductSyncPage: React.FC = () => {
if (!res.success) {
hide();
throw new Error(res.message || '同步失败');
}
// 更新本地缓存 Map避免刷新
setWpProductMap((prev) => {
const newMap = new Map(prev);
if (res.data && typeof res.data === 'object') {
newMap.set(values.sku, res.data as WpProduct);
}
return newMap;
});
// 更新本地缓存 Map避免刷新
setWpProductMap((prev) => {
const newMap = new Map(prev);
if (res.data && typeof res.data === 'object') {
newMap.set(values.sku, res.data as WpProduct);
}
return newMap;
});
hide();
message.success('同步成功');
@ -173,8 +192,139 @@ const ProductSyncPage: React.FC = () => {
} catch (error: any) {
message.error('同步失败: ' + (error.message || error.toString()));
return false;
}finally {
} finally {
}
};
// 批量同步产品到指定站点
const batchSyncProducts = async () => {
if (!selectedSiteId) {
message.error('请选择要同步到的站点');
return;
}
const targetSite = sites.find(site => site.id === selectedSiteId);
if (!targetSite) {
message.error('选择的站点不存在');
return;
}
setSyncing(true);
setSyncProgress(0);
setSyncResults({ success: 0, failed: 0, errors: [] });
try {
// 获取所有产品
const { data, success } = await productcontrollerGetproductlist({
current: 1,
pageSize: 10000, // 获取所有产品
} as any);
if (!success || !data?.items) {
message.error('获取产品列表失败');
return;
}
const products = data.items as ProductWithWP[];
const totalProducts = products.length;
let processed = 0;
let successCount = 0;
let failedCount = 0;
const errors: string[] = [];
// 逐个同步产品
for (const product of products) {
try {
// 获取该产品在目标站点的SKU
let siteProductSku = '';
if (product.siteSkus && product.siteSkus.length > 0) {
const siteSkuInfo = product.siteSkus.find((sku: any) => {
return sku.siteSku && sku.siteSku.includes(targetSite.skuPrefix || targetSite.name);
});
if (siteSkuInfo) {
siteProductSku = siteSkuInfo.siteSku;
}
}
// 如果没有找到实际的siteSku则根据模板生成
const expectedSku = siteProductSku || (
skuTemplate
? renderSku(skuTemplate, { site: targetSite, product })
: `${targetSite.skuPrefix || ''}-${product.sku}`
);
// 检查是否已存在
const existingProduct = wpProductMap.get(expectedSku);
// 准备同步数据
const syncData = {
name: product.name,
sku: expectedSku,
regular_price: product.price?.toString(),
sale_price: product.promotionPrice?.toString(),
type: product.type === 'bundle' ? 'simple' : product.type,
description: product.description,
status: 'publish',
stock_status: 'instock',
manage_stock: false,
};
let res;
if (existingProduct?.externalProductId) {
// 更新现有产品
res = await request(`/site-api/${targetSite.id}/products/${existingProduct.externalProductId}`, {
method: 'PUT',
data: syncData,
});
} else {
// 创建新产品
res = await request(`/site-api/${targetSite.id}/products`, {
method: 'POST',
data: syncData,
});
}
console.log('res', res);
if (res.success) {
successCount++;
// 更新本地缓存
setWpProductMap((prev) => {
const newMap = new Map(prev);
if (res.data && typeof res.data === 'object') {
newMap.set(expectedSku, res.data as WpProduct);
}
return newMap;
});
} else {
failedCount++;
errors.push(`产品 ${product.sku}: ${res.message || '同步失败'}`);
}
} catch (error: any) {
failedCount++;
errors.push(`产品 ${product.sku}: ${error.message || '未知错误'}`);
}
processed++;
setSyncProgress(Math.round((processed / totalProducts) * 100));
}
setSyncResults({ success: successCount, failed: failedCount, errors });
if (failedCount === 0) {
message.success(`批量同步完成,成功同步 ${successCount} 个产品`);
} else {
message.warning(`批量同步完成,成功 ${successCount} 个,失败 ${failedCount}`);
}
// 刷新表格
actionRef.current?.reload();
} catch (error: any) {
message.error('批量同步失败: ' + (error.message || error.toString()));
} finally {
setSyncing(false);
}
};
@ -182,15 +332,18 @@ const ProductSyncPage: React.FC = () => {
const renderSku = (template: string, data: any) => {
if (!template) return '';
// 支持 <%= it.path %> (Eta) 和 {{ path }} (Mustache/Handlebars)
return template.replace(/<%=\s*it\.([\w.]+)\s*%>|\{\{\s*([\w.]+)\s*\}\}/g, (_, p1, p2) => {
return template.replace(
/<%=\s*it\.([\w.]+)\s*%>|\{\{\s*([\w.]+)\s*\}\}/g,
(_, p1, p2) => {
const path = p1 || p2;
const keys = path.split('.');
let value = data;
for (const key of keys) {
value = value?.[key];
value = value?.[key];
}
return value === undefined || value === null ? '' : String(value);
});
},
);
};
// 生成表格列配置
@ -211,47 +364,75 @@ const ProductSyncPage: React.FC = () => {
fixed: 'left',
render: (_, record) => (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<div style={{ fontWeight: 'bold', fontSize: 14 }}>
{record.name}
</div>
<EditForm
record={record}
tableRef={actionRef}
trigger={<EditOutlined style={{ cursor: 'pointer', fontSize: 16, color: '#1890ff' }} />}
/>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
}}
>
<div style={{ fontWeight: 'bold', fontSize: 14 }}>
{record.name}
</div>
<EditForm
record={record}
tableRef={actionRef}
trigger={
<EditOutlined
style={{
cursor: 'pointer',
fontSize: 16,
color: '#1890ff',
}}
/>
}
/>
</div>
<div style={{ fontSize: 12, color: '#666' }}>
<span style={{ marginRight: 8 }}>
: {record.price}
</span>
<span style={{ marginRight: 8 }}>: {record.price}</span>
{record.promotionPrice && (
<span style={{ color: 'red' }}>
: {record.promotionPrice}
</span>
)}
</div>
{/* 属性 */}
<div style={{ marginTop: 4 }}>
{record.attributes?.map((attr: any, idx: number) => (
<Tag key={idx} style={{ fontSize: 10, marginRight: 4, marginBottom: 2 }}>
{attr.dict?.name || attr.name}: {attr.name}
</Tag>
))}
{record.attributes?.map((attr: any, idx: number) => (
<Tag
key={idx}
style={{ fontSize: 10, marginRight: 4, marginBottom: 2 }}
>
{attr.dict?.name || attr.name}: {attr.name}
</Tag>
))}
</div>
{/* 组成 (如果是 Bundle) */}
{record.type === 'bundle' && record.components && record.components.length > 0 && (
<div style={{ marginTop: 8, fontSize: 12, background: '#f5f5f5', padding: 4, borderRadius: 4 }}>
<div style={{ fontWeight: 'bold', marginBottom: 2 }}>Components:</div>
{record.components.map((comp: any, idx: number) => (
<div key={idx}>
{comp.sku} × {comp.quantity}
{record.type === 'bundle' &&
record.components &&
record.components.length > 0 && (
<div
style={{
marginTop: 8,
fontSize: 12,
background: '#f5f5f5',
padding: 4,
borderRadius: 4,
}}
>
<div style={{ fontWeight: 'bold', marginBottom: 2 }}>
Components:
</div>
))}
</div>
)}
{record.components.map((comp: any, idx: number) => (
<div key={idx}>
{comp.sku} × {comp.quantity}
</div>
))}
</div>
)}
</div>
),
},
@ -265,18 +446,34 @@ const ProductSyncPage: React.FC = () => {
hideInSearch: true,
width: 220,
render: (_, record) => {
// 根据模板或默认规则生成期望的 SKU
const expectedSku = skuTemplate
? renderSku(skuTemplate, { site, product: record })
: `${site.skuPrefix || ''}-${record.sku}`;
// 尝试用期望的 SKU 获取 WP 产品
// 首先查找该产品在该站点的实际SKU
let siteProductSku = '';
if (record.siteSkus && record.siteSkus.length > 0) {
// 根据站点名称匹配对应的siteSku
const siteSkuInfo = record.siteSkus.find((sku: any) => {
// 这里假设可以根据站点名称或其他标识来匹配
// 如果需要更精确的匹配逻辑,可以根据实际需求调整
return sku.siteSku && sku.siteSku.includes(site.skuPrefix || site.name);
});
if (siteSkuInfo) {
siteProductSku = siteSkuInfo.siteSku;
}
}
// 如果没有找到实际的siteSku则根据模板或默认规则生成期望的SKU
const expectedSku = siteProductSku || (
skuTemplate
? renderSku(skuTemplate, { site, product: record })
: `${site.skuPrefix || ''}-${record.sku}`
);
// 尝试用确定的SKU获取WP产品
let wpProduct = wpProductMap.get(expectedSku);
// 如果没找到,且没有模板(或者即使有模板),尝试回退到默认规则查找(以防万一)
if (!wpProduct && skuTemplate) {
const fallbackSku = `${site.skuPrefix || ''}-${record.sku}`;
wpProduct = wpProductMap.get(fallbackSku);
// 如果根据实际SKU没找到再尝试用模板生成的SKU查找
if (!wpProduct && siteProductSku && skuTemplate) {
const templateSku = renderSku(skuTemplate, { site, product: record });
wpProduct = wpProductMap.get(templateSku);
}
if (!wpProduct) {
@ -284,66 +481,89 @@ const ProductSyncPage: React.FC = () => {
<ModalForm
title="同步产品"
trigger={
<Button type="link" icon={<SyncOutlined />}>
</Button>
<Button type="link" icon={<SyncOutlined />}>
</Button>
}
width={400}
onFinish={async (values) => {
return await syncProductToSite(values, record, site);
return await syncProductToSite(values, record, site);
}}
initialValues={{
sku: skuTemplate
? renderSku(skuTemplate, { site, product: record })
sku: siteProductSku || (
skuTemplate
? renderSku(skuTemplate, { site, product: record })
: `${site.skuPrefix || ''}-${record.sku}`
}}
),
}}
>
<ProFormText
name="sku"
label="商店 SKU"
placeholder="请输入商店 SKU"
rules={[{ required: true, message: '请输入 SKU' }]}
name="sku"
label="商店 SKU"
placeholder="请输入商店 SKU"
rules={[{ required: true, message: '请输入 SKU' }]}
/>
</ModalForm>
);
}
return (
<div style={{ fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div style={{ fontWeight: 'bold' }}>{wpProduct.sku}</div>
<ModalForm
title="更新同步"
trigger={
<Button type="link" size="small" icon={<SyncOutlined spin={false} />}>
</Button>
}
width={400}
onFinish={async (values) => {
return await syncProductToSite(values, record, site, wpProduct.externalProductId);
}}
initialValues={{
sku: wpProduct.sku
}}
>
<ProFormText
name="sku"
label="商店 SKU"
placeholder="请输入商店 SKU"
rules={[{ required: true, message: '请输入 SKU' }]}
disabled
/>
<div style={{ marginBottom: 16, color: '#666' }}>
</div>
</ModalForm>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'start',
}}
>
<div style={{ fontWeight: 'bold' }}>{wpProduct.sku}</div>
<ModalForm
title="更新同步"
trigger={
<Button
type="link"
size="small"
icon={<SyncOutlined spin={false} />}
></Button>
}
width={400}
onFinish={async (values) => {
return await syncProductToSite(
values,
record,
site,
wpProduct.externalProductId,
);
}}
initialValues={{
sku: wpProduct.sku,
}}
>
<ProFormText
name="sku"
label="商店 SKU"
placeholder="请输入商店 SKU"
rules={[{ required: true, message: '请输入 SKU' }]}
disabled
/>
<div style={{ marginBottom: 16, color: '#666' }}>
</div>
</ModalForm>
</div>
<div>Price: {wpProduct.regular_price ?? wpProduct.price}</div>
{wpProduct.sale_price && (
<div style={{ color: 'red' }}>Sale: {wpProduct.sale_price}</div>
)}
<div>Stock: {wpProduct.stock_quantity ?? wpProduct.stockQuantity}</div>
<div>
Stock: {wpProduct.stock_quantity ?? wpProduct.stockQuantity}
</div>
<div style={{ marginTop: 2 }}>
Status: {wpProduct.status === 'publish' ? <Tag color="green">Published</Tag> : <Tag>{wpProduct.status}</Tag>}
Status:{' '}
{wpProduct.status === 'publish' ? (
<Tag color="green">Published</Tag>
) : (
<Tag>{wpProduct.status}</Tag>
)}
</div>
</div>
);
@ -356,52 +576,137 @@ const ProductSyncPage: React.FC = () => {
};
if (initialLoading) {
return (
<Card title="商品同步状态" className="product-sync-card">
<Spin size="large" style={{ display: 'flex', justifyContent: 'center', padding: 40 }} />
</Card>
)
return (
<Card title="商品同步状态" className="product-sync-card">
<Spin
size="large"
style={{ display: 'flex', justifyContent: 'center', padding: 40 }}
/>
</Card>
);
}
return (
<Card title="商品同步状态" className="product-sync-card">
<ProTable<ProductWithWP>
columns={generateColumns()}
actionRef={actionRef}
rowKey="id"
request={async (params, sort, filter) => {
// 调用本地获取产品列表 API
const { data, success } = await productcontrollerGetproductlist({
...params,
current: params.current,
pageSize: params.pageSize,
// 传递搜索参数
keyword: params.keyword, // 假设 ProTable 的 search 表单会传递 keyword 或其他字段
sku: (params as any).sku,
name: (params as any).name,
} as any);
<Card
title="商品同步状态"
className="product-sync-card"
extra={
<div style={{ display: 'flex', gap: 8 }}>
<Select
style={{ width: 200 }}
placeholder="选择目标站点"
value={selectedSiteId}
onChange={setSelectedSiteId}
options={sites.map(site => ({
label: site.name,
value: site.id,
}))}
/>
<Button
type="primary"
icon={<SyncOutlined />}
onClick={() => setBatchSyncModalVisible(true)}
disabled={!selectedSiteId || sites.length === 0}
>
</Button>
</div>
}
>
<ProTable<ProductWithWP>
columns={generateColumns()}
actionRef={actionRef}
rowKey="id"
request={async (params, sort, filter) => {
// 调用本地获取产品列表 API
const { data, success } = await productcontrollerGetproductlist({
...params,
current: params.current,
pageSize: params.pageSize,
// 传递搜索参数
keyword: params.keyword, // 假设 ProTable 的 search 表单会传递 keyword 或其他字段
sku: (params as any).sku,
name: (params as any).name,
} as any);
// 返回给 ProTable
return {
data: (data?.items || []) as ProductWithWP[],
success,
total: data?.total || 0,
};
}}
pagination={{
pageSize: 10,
showSizeChanger: true,
}}
scroll={{ x: 'max-content' }}
search={{
labelWidth: 'auto',
}}
options={{
density: true,
fullScreen: true,
}}
dateFormatter="string"
/>
// 返回给 ProTable
return {
data: (data?.items || []) as ProductWithWP[],
success,
total: data?.total || 0,
};
}}
pagination={{
pageSize: 10,
showSizeChanger: true,
}}
scroll={{ x: 'max-content' }}
search={{
labelWidth: 'auto',
}}
options={{
density: true,
fullScreen: true,
}}
dateFormatter="string"
/>
{/* 批量同步模态框 */}
<Modal
title="批量同步产品"
open={batchSyncModalVisible}
onCancel={() => !syncing && setBatchSyncModalVisible(false)}
footer={null}
closable={!syncing}
maskClosable={!syncing}
>
<div style={{ marginBottom: 16 }}>
<p><strong>{sites.find(s => s.id === selectedSiteId)?.name}</strong></p>
<p></p>
</div>
{syncing && (
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}></div>
<Progress percent={syncProgress} status="active" />
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
{syncResults.success} | {syncResults.failed}
</div>
</div>
)}
{syncResults.errors.length > 0 && (
<div style={{ marginBottom: 16, maxHeight: 200, overflow: 'auto' }}>
<div style={{ marginBottom: 8, color: '#ff4d4f' }}></div>
{syncResults.errors.slice(0, 10).map((error, index) => (
<div key={index} style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
{error}
</div>
))}
{syncResults.errors.length > 10 && (
<div style={{ fontSize: 12, color: '#999' }}>... {syncResults.errors.length - 10} </div>
)}
</div>
)}
<div style={{ textAlign: 'right' }}>
<Button
onClick={() => setBatchSyncModalVisible(false)}
disabled={syncing}
style={{ marginRight: 8 }}
>
</Button>
<Button
type="primary"
onClick={batchSyncProducts}
loading={syncing}
disabled={syncing}
>
{syncing ? '同步中...' : '开始同步'}
</Button>
</div>
</Modal>
</Card>
);
};

View File

@ -1,18 +1,13 @@
import {
ActionType,
ProColumns,
ProTable,
} from '@ant-design/pro-components';
import { ordercontrollerSyncorder } from '@/servers/api/order';
import {
sitecontrollerCreate,
sitecontrollerDisable,
sitecontrollerList,
sitecontrollerUpdate,
} from '@/servers/api/site';
import { wpproductcontrollerSyncproducts } from '@/servers/api/wpProduct';
import { ordercontrollerSyncorder } from '@/servers/api/order';
import { subscriptioncontrollerSync } from '@/servers/api/subscription';
import { request } from '@umijs/max';
import { wpproductcontrollerSyncproducts } from '@/servers/api/wpProduct';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { Button, message, notification, Popconfirm, Space, Tag } from 'antd';
import React, { useRef, useState } from 'react';
import EditSiteForm from '../Shop/EditSiteForm'; // 引入重构后的表单组件
@ -91,9 +86,16 @@ const SiteList: React.FC = () => {
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>
<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, // 不自动关闭
@ -124,7 +126,11 @@ const SiteList: React.FC = () => {
dataIndex: 'websiteUrl',
width: 280,
hideInSearch: true,
render: (text) => <a href={text as string} target="_blank" rel="noopener noreferrer">{text}</a>
render: (text) => (
<a href={text as string} target="_blank" rel="noopener noreferrer">
{text}
</a>
),
},
{
title: 'SKU 前缀',
@ -154,7 +160,9 @@ const SiteList: React.FC = () => {
return (
<Space wrap>
{row.stockPoints.map((sp) => (
<Tag color="blue" key={sp.id}>{sp.name}</Tag>
<Tag color="blue" key={sp.id}>
{sp.name}
</Tag>
))}
</Space>
);
@ -175,7 +183,7 @@ const SiteList: React.FC = () => {
title: '操作',
dataIndex: 'actions',
width: 240,
fixed:"right",
fixed: 'right',
hideInSearch: true,
render: (_, row) => (
<Space>
@ -197,12 +205,13 @@ const SiteList: React.FC = () => {
</Button>
<Popconfirm
title={row.isDisabled ? '启用站点' : '禁用站点'}
description={
row.isDisabled ? '确认启用该站点?' : '确认禁用该站点?'
}
description={row.isDisabled ? '确认启用该站点?' : '确认禁用该站点?'}
onConfirm={async () => {
try {
await sitecontrollerDisable({ id: String(row.id) }, { disabled: !row.isDisabled });
await sitecontrollerDisable(
{ id: String(row.id) },
{ disabled: !row.isDisabled },
);
message.success('更新成功');
actionRef.current?.reload();
} catch (e: any) {

View File

@ -1,8 +1,22 @@
import { ActionType, DrawerForm, ModalForm, PageContainer, ProColumns, ProFormText, ProFormTextArea, ProTable } from '@ant-design/pro-components';
import {
DeleteFilled,
EditOutlined,
PlusOutlined,
UserOutlined,
} from '@ant-design/icons';
import {
ActionType,
DrawerForm,
ModalForm,
PageContainer,
ProColumns,
ProFormText,
ProFormTextArea,
ProTable,
} from '@ant-design/pro-components';
import { request, useParams } from '@umijs/max';
import { App, Avatar, Button, Modal, Popconfirm, Space, Tag } from 'antd';
import React, { useEffect, useRef, useState } from 'react';
import { DeleteFilled, EditOutlined, PlusOutlined, UserOutlined } from '@ant-design/icons';
const BatchEditCustomers: React.FC<{
tableRef: React.MutableRefObject<ActionType | undefined>;
@ -27,22 +41,27 @@ const BatchEditCustomers: React.FC<{
modalProps={{ destroyOnHidden: true }}
onFinish={async (values) => {
if (!siteId) return false;
let ok = 0, fail = 0;
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;
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++;
}
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();
@ -50,8 +69,16 @@ const BatchEditCustomers: React.FC<{
return true;
}}
>
<ProFormText name="role" label="角色" placeholder="请输入角色,不修改请留空" />
<ProFormText name="phone" label="电话" placeholder="请输入电话,不修改请留空" />
<ProFormText
name="role"
label="角色"
placeholder="请输入角色,不修改请留空"
/>
<ProFormText
name="phone"
label="电话"
placeholder="请输入电话,不修改请留空"
/>
</ModalForm>
);
};
@ -75,7 +102,9 @@ const CustomerPage: React.FC = () => {
const handleDelete = async (id: number) => {
if (!siteId) return;
try {
const res = await request(`/site-api/${siteId}/customers/${id}`, { method: 'DELETE' });
const res = await request(`/site-api/${siteId}/customers/${id}`, {
method: 'DELETE',
});
if (res.success) {
message.success('删除成功');
actionRef.current?.reload();
@ -112,7 +141,7 @@ const CustomerPage: React.FC = () => {
copyable: true,
render: (_, record) => {
return record?.id ?? '-';
}
},
},
{
title: '姓名',
@ -139,7 +168,8 @@ const CustomerPage: React.FC = () => {
{
title: '电话',
dataIndex: 'phone',
render: (_, record) => record.phone || record.billing?.phone || record.shipping?.phone || '-',
render: (_, record) =>
record.phone || record.billing?.phone || record.shipping?.phone || '-',
copyable: true,
},
{
@ -159,12 +189,16 @@ const CustomerPage: React.FC = () => {
const { billing } = record;
if (!billing) return '-';
return (
<div style={{ fontSize: 12 }}>
<div>{billing.address_1} {billing.address_2}</div>
<div>{billing.city}, {billing.state}, {billing.postcode}</div>
<div>{billing.country}</div>
<div>{billing.phone}</div>
<div style={{ fontSize: 12 }}>
<div>
{billing.address_1} {billing.address_2}
</div>
<div>
{billing.city}, {billing.state}, {billing.postcode}
</div>
<div>{billing.country}</div>
<div>{billing.phone}</div>
</div>
);
},
},
@ -178,14 +212,29 @@ const CustomerPage: React.FC = () => {
title: '操作',
valueType: 'option',
width: 120,
fixed:"right",
fixed: 'right',
render: (_, record) => (
<Space>
<Button type="link" title="编辑" icon={<EditOutlined />} onClick={() => setEditing(record)} />
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record.id)}>
<Button
type="link"
title="编辑"
icon={<EditOutlined />}
onClick={() => setEditing(record)}
/>
<Popconfirm
title="确定删除?"
onConfirm={() => handleDelete(record.id)}
>
<Button type="link" danger title="删除" icon={<DeleteFilled />} />
</Popconfirm>
<Button type="link" title="查询订单" onClick={() => { setOrdersCustomer(record); setOrdersVisible(true); }}>
<Button
type="link"
title="查询订单"
onClick={() => {
setOrdersCustomer(record);
setOrdersVisible(true);
}}
>
</Button>
</Space>
@ -195,11 +244,11 @@ const CustomerPage: React.FC = () => {
return (
<PageContainer
ghost
header={{
title: null,
breadcrumb: undefined
}}
ghost
header={{
title: null,
breadcrumb: undefined,
}}
>
<ProTable
rowKey="id"
@ -232,7 +281,7 @@ const CustomerPage: React.FC = () => {
page_size: pageSize,
where,
...(orderObj ? { order: orderObj } : {}),
...((name || email) ? { search: name || email } : {}),
...(name || email ? { search: name || email } : {}),
},
});
@ -255,10 +304,15 @@ const CustomerPage: React.FC = () => {
toolBarRender={() => [
<DrawerForm
title="新增客户"
trigger={<Button type="primary" title="新增" icon={<PlusOutlined />} />}
trigger={
<Button type="primary" title="新增" icon={<PlusOutlined />} />
}
onFinish={async (values) => {
if (!siteId) return false;
const res = await request(`/site-api/${siteId}/customers`, { method: 'POST', data: values });
const res = await request(`/site-api/${siteId}/customers`, {
method: 'POST',
data: values,
});
if (res.success) {
message.success('新增成功');
actionRef.current?.reload();
@ -268,11 +322,15 @@ const CustomerPage: React.FC = () => {
return false;
}}
>
<ProFormText name="email" label="邮箱" rules={[{ required: true }]} />
<ProFormText
name="email"
label="邮箱"
rules={[{ required: true }]}
/>
<ProFormText name="first_name" label="名" />
<ProFormText name="last_name" label="姓" />
<ProFormText name="username" label="用户名" />
<ProFormText name="phone" label="电话" />
<ProFormText name="phone" label="电话" />
</DrawerForm>,
<BatchEditCustomers
tableRef={actionRef}
@ -284,10 +342,17 @@ const CustomerPage: React.FC = () => {
title="批量导出"
onClick={async () => {
if (!siteId) return;
const idsParam = selectedRowKeys.length ? (selectedRowKeys as any[]).join(',') : undefined;
const res = await request(`/site-api/${siteId}/customers/export`, { params: { ids: idsParam } });
const idsParam = selectedRowKeys.length
? (selectedRowKeys as any[]).join(',')
: undefined;
const res = await request(
`/site-api/${siteId}/customers/export`,
{ params: { ids: idsParam } },
);
if (res?.success && res?.data?.csv) {
const blob = new Blob([res.data.csv], { type: 'text/csv;charset=utf-8;' });
const blob = new Blob([res.data.csv], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@ -298,17 +363,26 @@ const CustomerPage: React.FC = () => {
message.error(res.message || '导出失败');
}
}}
></Button>,
>
</Button>,
<ModalForm
title="批量导入客户"
trigger={<Button type="primary" ghost></Button>}
trigger={
<Button type="primary" ghost>
</Button>
}
width={600}
modalProps={{ destroyOnHidden: true }}
onFinish={async (values) => {
if (!siteId) return false;
const csv = values.csv || '';
const items = values.items || [];
const res = await request(`/site-api/${siteId}/customers/import`, { method: 'POST', data: { csv, items } });
const res = await request(
`/site-api/${siteId}/customers/import`,
{ method: 'POST', data: { csv, items } },
);
if (res.success) {
message.success('导入完成');
actionRef.current?.reload();
@ -318,7 +392,11 @@ const CustomerPage: React.FC = () => {
return false;
}}
>
<ProFormTextArea name="csv" label="CSV文本" placeholder="粘贴CSV,首行为表头" />
<ProFormTextArea
name="csv"
label="CSV文本"
placeholder="粘贴CSV,首行为表头"
/>
</ModalForm>,
<Button
@ -327,7 +405,10 @@ const CustomerPage: React.FC = () => {
icon={<DeleteFilled />}
onClick={async () => {
if (!siteId) return;
const res = await request(`/site-api/${siteId}/customers/batch`, { method: 'POST', data: { delete: selectedRowKeys } });
const res = await request(`/site-api/${siteId}/customers/batch`, {
method: 'POST',
data: { delete: selectedRowKeys },
});
actionRef.current?.reload();
setSelectedRowKeys([]);
if (res.success) {
@ -336,7 +417,7 @@ const CustomerPage: React.FC = () => {
message.warning(res.message || '部分删除失败');
}
}}
/>
/>,
]}
/>
@ -347,7 +428,10 @@ const CustomerPage: React.FC = () => {
initialValues={editing || {}}
onFinish={async (values) => {
if (!siteId || !editing) return false;
const res = await request(`/site-api/${siteId}/customers/${editing.id}`, { method: 'PUT', data: values });
const res = await request(
`/site-api/${siteId}/customers/${editing.id}`,
{ method: 'PUT', data: values },
);
if (res.success) {
message.success('更新成功');
actionRef.current?.reload();
@ -362,11 +446,14 @@ const CustomerPage: React.FC = () => {
<ProFormText name="first_name" label="名" />
<ProFormText name="last_name" label="姓" />
<ProFormText name="username" label="用户名" />
<ProFormText name="phone" label="电话" />
<ProFormText name="phone" label="电话" />
</DrawerForm>
<Modal
open={ordersVisible}
onCancel={() => { setOrdersVisible(false); setOrdersCustomer(null); }}
onCancel={() => {
setOrdersVisible(false);
setOrdersCustomer(null);
}}
footer={null}
width={1000}
title="客户订单"
@ -386,7 +473,12 @@ const CustomerPage: React.FC = () => {
return ordersCustomer?.email;
},
},
{ title: '支付时间', dataIndex: 'date_paid', valueType: 'dateTime', hideInSearch: true },
{
title: '支付时间',
dataIndex: 'date_paid',
valueType: 'dateTime',
hideInSearch: true,
},
{ title: '订单金额', dataIndex: 'total', hideInSearch: true },
{ title: '状态', dataIndex: 'status', hideInSearch: true },
{ title: '来源', dataIndex: 'created_via', hideInSearch: true },
@ -408,13 +500,17 @@ const CustomerPage: React.FC = () => {
},
]}
request={async (params) => {
if (!siteId || !ordersCustomer?.id) return { data: [], total: 0, success: true };
const res = await request(`/site-api/${siteId}/customers/${ordersCustomer.id}/orders`, {
params: {
page: params.current,
per_page: params.pageSize,
if (!siteId || !ordersCustomer?.id)
return { data: [], total: 0, success: true };
const res = await request(
`/site-api/${siteId}/customers/${ordersCustomer.id}/orders`,
{
params: {
page: params.current,
per_page: params.pageSize,
},
},
});
);
if (!res?.success) {
message.error(res?.message || '获取订单失败');
return { data: [], total: 0, success: false };

View File

@ -1,4 +1,5 @@
import { areacontrollerGetarealist } from '@/servers/api/area';
import { stockcontrollerGetallstockpoints } from '@/servers/api/stock';
import {
DrawerForm,
ProFormDependency,
@ -9,8 +10,6 @@ import {
} from '@ant-design/pro-components';
import { Form } from 'antd';
import React, { useEffect } from 'react';
import { areacontrollerGetarealist } from '@/servers/api/area';
import { stockcontrollerGetallstockpoints } from '@/servers/api/stock';
// 定义组件的 props 类型
interface EditSiteFormProps {
@ -67,7 +66,11 @@ const EditSiteForm: React.FC<EditSiteFormProps> = ({
rules={[{ required: true, message: '请输入名称' }]}
placeholder="请输入名称"
/>
<ProFormTextArea name="description" label="描述" placeholder="请输入描述" />
<ProFormTextArea
name="description"
label="描述"
placeholder="请输入描述"
/>
<ProFormText
name="apiUrl"
label="API 地址"
@ -99,14 +102,22 @@ const EditSiteForm: React.FC<EditSiteFormProps> = ({
<ProFormText
name="consumerKey"
label="Consumer Key"
rules={[{ required: !isEdit, message: '请输入 Consumer Key' }]}
placeholder={isEdit ? '留空表示不修改' : '请输入 Consumer Key'}
rules={[
{ required: !isEdit, message: '请输入 Consumer Key' },
]}
placeholder={
isEdit ? '留空表示不修改' : '请输入 Consumer Key'
}
/>
<ProFormText
name="consumerSecret"
label="Consumer Secret"
rules={[{ required: !isEdit, message: '请输入 Consumer Secret' }]}
placeholder={isEdit ? '留空表示不修改' : '请输入 Consumer Secret'}
rules={[
{ required: !isEdit, message: '请输入 Consumer Secret' },
]}
placeholder={
isEdit ? '留空表示不修改' : '请输入 Consumer Secret'
}
/>
</>
);
@ -125,7 +136,11 @@ const EditSiteForm: React.FC<EditSiteFormProps> = ({
return null;
}}
</ProFormDependency>
<ProFormText name="skuPrefix" label="SKU 前缀" placeholder="请输入 SKU 前缀" />
<ProFormText
name="skuPrefix"
label="SKU 前缀"
placeholder="请输入 SKU 前缀"
/>
<ProFormSelect
name="areas"
label="区域"
@ -135,7 +150,10 @@ const EditSiteForm: React.FC<EditSiteFormProps> = ({
// 从后端接口获取区域数据
const res = await areacontrollerGetarealist({ pageSize: 1000 });
// areacontrollerGetarealist 直接返回数组, 所以不需要 .data.list
return res.map((area: any) => ({ label: area.name, value: area.code }));
return res.map((area: any) => ({
label: area.name,
value: area.code,
}));
}}
/>
<ProFormSelect
@ -147,7 +165,10 @@ const EditSiteForm: React.FC<EditSiteFormProps> = ({
// 从后端接口获取仓库数据
const res = await stockcontrollerGetallstockpoints();
// 使用可选链和空值合并运算符来安全地处理可能未定义的数据
return res?.data?.map((sp: any) => ({ label: sp.name, value: sp.id })) ?? [];
return (
res?.data?.map((sp: any) => ({ label: sp.name, value: sp.id })) ??
[]
);
}}
/>
<ProFormSwitch name="isDisabled" label="是否禁用" />

View File

@ -1,14 +1,11 @@
import { EditOutlined } from '@ant-design/icons';
import { request } from '@umijs/max';
import { sitecontrollerAll } from '@/servers/api/site';
import {
PageContainer,
} from '@ant-design/pro-components';
import { Outlet, history, useLocation, useParams } from '@umijs/max';
import { EditOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { Outlet, history, request, useLocation, useParams } from '@umijs/max';
import { Button, Card, Col, Menu, Row, Select, Spin, message } from 'antd';
import React, { useEffect, useState } from 'react';
import EditSiteForm from './EditSiteForm';
import type { SiteItem } from '../List/index';
import EditSiteForm from './EditSiteForm';
const ShopLayout: React.FC = () => {
const [sites, setSites] = useState<any[]>([]);
@ -102,15 +99,29 @@ const ShopLayout: React.FC = () => {
<Row gutter={16} style={{ height: 'calc(100vh - 100px)' }}>
<Col span={4} style={{ height: '100%' }}>
<Card
bodyStyle={{ padding: '10px 0', height: '100%', display: 'flex', flexDirection: 'column' }}
bodyStyle={{
padding: '10px 0',
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
style={{ height: '100%', overflow: 'hidden' }}
>
<div style={{ padding: '0 10px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<Select
style={{ flex: 1 }}
placeholder="请选择店铺"
options={sites.map(site => ({ label: site.name, value: site.id }))}
options={sites.map((site) => ({
label: site.name,
value: site.id,
}))}
value={siteId ? Number(siteId) : undefined}
onChange={handleSiteChange}
showSearch
@ -120,7 +131,9 @@ const ShopLayout: React.FC = () => {
icon={<EditOutlined />}
style={{ marginLeft: 8 }}
onClick={() => {
const currentSite = sites.find(site => site.id === Number(siteId));
const currentSite = sites.find(
(site) => site.id === Number(siteId),
);
if (currentSite) {
setEditingSite(currentSite);
setEditModalOpen(true);

View File

@ -7,7 +7,12 @@ import {
import { stockcontrollerGetallstockpoints } from '@/servers/api/stock';
import { formatUniuniShipmentState } from '@/utils/format';
import { printPDF } from '@/utils/util';
import { CopyOutlined, FilePdfOutlined, ReloadOutlined, DeleteFilled } from '@ant-design/icons';
import {
CopyOutlined,
DeleteFilled,
FilePdfOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import {
ActionType,
PageContainer,
@ -150,7 +155,12 @@ const LogisticsPage: React.FC = () => {
}
}}
>
<Button type="primary" danger title="删除" icon={<DeleteFilled />} />
<Button
type="primary"
danger
title="删除"
icon={<DeleteFilled />}
/>
</Popconfirm>
<ToastContainer />
</>
@ -183,7 +193,11 @@ const LogisticsPage: React.FC = () => {
if (siteId) {
params.siteId = Number(siteId);
}
const { data, success, message: errMsg } = await logisticscontrollerGetlist({
const {
data,
success,
message: errMsg,
} = await logisticscontrollerGetlist({
params,
});
if (success) {
@ -217,8 +231,9 @@ const LogisticsPage: React.FC = () => {
setIsLoading(true);
let ok = 0;
for (const row of selectedRows) {
const { success } = await logisticscontrollerDeleteshipment({ id: row.id });
if (success) ok++;
const { success } =
await logisticscontrollerDeleteshipment({ id: row.id });
if (success) ok++;
}
message.success(`成功删除 ${ok}`);
setIsLoading(false);

View File

@ -1,8 +1,16 @@
import { ModalForm, PageContainer, ProColumns, ProFormText, ProFormTextArea, ProFormUploadButton, ProTable } from '@ant-design/pro-components';
import { siteapicontrollerCreatemedia } from '@/servers/api/siteApi';
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
import {
ModalForm,
PageContainer,
ProColumns,
ProFormText,
ProFormUploadButton,
ProTable,
} from '@ant-design/pro-components';
import { request, useParams } from '@umijs/max';
import { App, Button, Image, Popconfirm, Space } from 'antd';
import React, { useEffect, useState } from 'react';
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
import React, { useState } from 'react';
const MediaPage: React.FC = () => {
const { message } = App.useApp();
@ -62,7 +70,7 @@ const MediaPage: React.FC = () => {
copyable: true,
render: (_, record) => {
return record?.id ?? '-';
}
},
},
{
title: '展示',
@ -71,7 +79,12 @@ const MediaPage: React.FC = () => {
render: (_, record) => (
<Image
src={record.source_url}
style={{ width: 60, height: 60, objectFit: 'contain', background: '#f0f0f0' }}
style={{
width: 60,
height: 60,
objectFit: 'contain',
background: '#f0f0f0',
}}
fallback="https://via.placeholder.com/60?text=No+Img"
/>
),
@ -100,6 +113,31 @@ const MediaPage: React.FC = () => {
dataIndex: 'mime_type',
width: 120,
},
{
// 文件大小列
title: '文件大小',
dataIndex: 'file_size',
hideInSearch: true,
width: 120,
render: (_: any, record: any) => {
// 获取文件大小
const fileSize = record.file_size;
// 如果文件大小不存在,则直接返回-
if (!fileSize) {
return '-';
}
// 如果文件大小小于1024,则单位为B
if (fileSize < 1024) {
return `${fileSize} B`;
// 如果文件大小小于1024*1024,则单位为KB
} else if (fileSize < 1024 * 1024) {
return `${(fileSize / 1024).toFixed(2)} KB`;
// 否则单位为MB
} else {
return `${(fileSize / (1024 * 1024)).toFixed(2)} MB`;
}
},
},
{
title: '创建时间',
dataIndex: 'date_created',
@ -140,11 +178,11 @@ const MediaPage: React.FC = () => {
return (
<PageContainer
ghost
header={{
title: null,
breadcrumb: undefined
}}
ghost
header={{
title: null,
breadcrumb: undefined,
}}
>
<ProTable
rowKey="id"
@ -169,7 +207,7 @@ const MediaPage: React.FC = () => {
...(orderObj ? { order: orderObj } : {}),
},
});
if (!response.success) {
message.error(response.message || '获取媒体列表失败');
return {
@ -178,7 +216,7 @@ const MediaPage: React.FC = () => {
success: false,
};
}
// 从API响应中正确获取数据API响应结构为 { success, message, data, code }
const data = response.data;
return {
@ -192,7 +230,11 @@ const MediaPage: React.FC = () => {
toolBarRender={() => [
<ModalForm
title="上传媒体"
trigger={<Button type="primary" title="上传媒体" icon={<PlusOutlined />}></Button>}
trigger={
<Button type="primary" title="上传媒体" icon={<PlusOutlined />}>
</Button>
}
width={500}
onFinish={async (values) => {
if (!siteId) return false;
@ -204,13 +246,12 @@ const MediaPage: React.FC = () => {
formData.append('file', f.originFileObj);
});
} else {
message.warning('请选择文件');
return false;
message.warning('请选择文件');
return false;
}
const res = await request('/media/upload', {
method: 'POST',
data: formData,
const res = await siteapicontrollerCreatemedia({
body: formData,
});
if (res.success) {
@ -230,23 +271,27 @@ const MediaPage: React.FC = () => {
<ProFormUploadButton
name="file"
label="文件"
max={1}
fieldProps={{
name: 'file',
listType: 'picture-card',
}}
rules={[{ required: true, message: '请选择文件' }]}
/>
</ModalForm>
,
</ModalForm>,
<Button
title="批量导出"
onClick={async () => {
if (!siteId) return;
const idsParam = selectedRowKeys.length ? (selectedRowKeys as any[]).join(',') : undefined;
const res = await request(`/site-api/${siteId}/media/export`, { params: { ids: idsParam } });
const idsParam = selectedRowKeys.length
? (selectedRowKeys as any[]).join(',')
: undefined;
const res = await request(`/site-api/${siteId}/media/export`, {
params: { ids: idsParam },
});
if (res?.success && res?.data?.csv) {
const blob = new Blob([res.data.csv], { type: 'text/csv;charset=utf-8;' });
const blob = new Blob([res.data.csv], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@ -269,7 +314,10 @@ const MediaPage: React.FC = () => {
// 条件判断 如果站点編號不存在則直接返回
if (!siteId) return;
// 发起批量删除请求
const response = await request(`/site-api/${siteId}/media/batch`, { method: 'POST', data: { delete: selectedRowKeys } });
const response = await request(
`/site-api/${siteId}/media/batch`,
{ method: 'POST', data: { delete: selectedRowKeys } },
);
// 条件判断 根据接口返回结果进行提示
if (response.success) {
message.success('批量删除成功');
@ -290,9 +338,8 @@ const MediaPage: React.FC = () => {
>
</Button>
</Popconfirm>
</Popconfirm>,
,
<Button
title="批量转换为WebP"
disabled={!selectedRowKeys.length}
@ -301,16 +348,21 @@ const MediaPage: React.FC = () => {
if (!siteId) return;
try {
// 发起后端批量转换请求
const response = await request(`/site-api/${siteId}/media/convert-webp`, {
method: 'POST',
data: { ids: selectedRowKeys },
});
const response = await request(
`/site-api/${siteId}/media/convert-webp`,
{
method: 'POST',
data: { ids: selectedRowKeys },
},
);
// 条件判断 根据接口返回结果进行提示
if (response.success) {
const convertedCount = response?.data?.converted?.length || 0;
const failedCount = response?.data?.failed?.length || 0;
if (failedCount > 0) {
message.warning(`部分转换失败 已转换 ${convertedCount} 失败 ${failedCount}`);
message.warning(
`部分转换失败 已转换 ${convertedCount} 失败 ${failedCount}`,
);
} else {
message.success(`转换成功 已转换 ${convertedCount}`);
}
@ -325,10 +377,10 @@ const MediaPage: React.FC = () => {
}}
>
WebP
</Button>
</Button>,
]}
/>
<ModalForm
title="编辑媒体信息"
open={!!editing}

View File

@ -1,36 +1,24 @@
import styles from '@/style/order-list.css';
import { ORDER_STATUS_ENUM } from '@/constants';
import { HistoryOrder } from '@/pages/Statistics/Order';
import {
ordercontrollerChangestatus,
} from '@/servers/api/order';
import { formatShipmentState, formatSource } from '@/utils/format';
import {
EllipsisOutlined,
} from '@ant-design/icons';
import styles from '@/style/order-list.css';
import { DeleteFilled, EllipsisOutlined } from '@ant-design/icons';
import {
ActionType,
ModalForm,
PageContainer,
ProColumns,
ProTable,
ModalForm,
ProFormTextArea,
ProTable,
} from '@ant-design/pro-components';
import { request, useParams } from '@umijs/max';
import {
App,
Button,
Divider,
Dropdown,
Popconfirm,
Space,
Tabs,
TabsProps,
Tag,
} from 'antd';
import { App, Button, Dropdown, Popconfirm, Tabs, TabsProps } from 'antd';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { BatchEditOrders, CreateOrder, EditOrder, OrderNote, Shipping } from '../components/Order/Forms';
import { DeleteFilled } from '@ant-design/icons';
import {
BatchEditOrders,
CreateOrder,
EditOrder,
OrderNote,
} from '../components/Order/Forms';
const OrdersPage: React.FC = () => {
const actionRef = useRef<ActionType>();
@ -70,10 +58,7 @@ const OrdersPage: React.FC = () => {
};
});
return [
{ key: 'all', label: `全部(${total})` },
...tabs,
];
return [{ key: 'all', label: `全部(${total})` }, ...tabs];
}, [count]);
const columns: ProColumns<API.Order>[] = [
@ -126,9 +111,7 @@ const OrdersPage: React.FC = () => {
return (
<div>
{record.line_items.map((item: any) => (
<div key={item.id}>
{`${item.name} x ${item.quantity}`}
</div>
<div key={item.id}>{`${item.name} x ${item.quantity}`}</div>
))}
</div>
);
@ -194,7 +177,9 @@ const OrdersPage: React.FC = () => {
},
{
key: 'note',
label: <OrderNote id={record.id as number} siteId={siteId} />,
label: (
<OrderNote id={record.id as number} siteId={siteId} />
),
},
],
}}
@ -205,7 +190,10 @@ const OrdersPage: React.FC = () => {
title="确定删除订单?"
onConfirm={async () => {
try {
const res = await request(`/site-api/${siteId}/orders/${record.id}`, { method: 'DELETE' });
const res = await request(
`/site-api/${siteId}/orders/${record.id}`,
{ method: 'DELETE' },
);
if (res.success) {
message.success('删除成功');
actionRef.current?.reload();
@ -272,15 +260,20 @@ const OrdersPage: React.FC = () => {
message.warning(res.message || '部分删除失败');
}
}}
/>
,
/>,
<Button
onClick={async () => {
if (!siteId) return;
const idsParam = selectedRowKeys.length ? (selectedRowKeys as any[]).join(',') : undefined;
const res = await request(`/site-api/${siteId}/orders/export`, { params: { ids: idsParam } });
const idsParam = selectedRowKeys.length
? (selectedRowKeys as any[]).join(',')
: undefined;
const res = await request(`/site-api/${siteId}/orders/export`, {
params: { ids: idsParam },
});
if (res?.success && res?.data?.csv) {
const blob = new Blob([res.data.csv], { type: 'text/csv;charset=utf-8;' });
const blob = new Blob([res.data.csv], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@ -291,17 +284,26 @@ const OrdersPage: React.FC = () => {
message.error(res.message || '导出失败');
}
}}
></Button>,
>
</Button>,
<ModalForm
title="批量导入订单"
trigger={<Button type="primary" ghost></Button>}
trigger={
<Button type="primary" ghost>
</Button>
}
width={600}
modalProps={{ destroyOnHidden: true }}
onFinish={async (values) => {
if (!siteId) return false;
const csv = values.csv || '';
const items = values.items || [];
const res = await request(`/site-api/${siteId}/orders/import`, { method: 'POST', data: { csv, items } });
const res = await request(`/site-api/${siteId}/orders/import`, {
method: 'POST',
data: { csv, items },
});
if (res.success) {
message.success('导入完成');
actionRef.current?.reload();
@ -311,9 +313,12 @@ const OrdersPage: React.FC = () => {
return false;
}}
>
<ProFormTextArea name="csv" label="CSV文本" placeholder="粘贴CSV,首行为表头" />
</ModalForm>
<ProFormTextArea
name="csv"
label="CSV文本"
placeholder="粘贴CSV,首行为表头"
/>
</ModalForm>,
]}
request={async (params, sort, filter) => {
const p: any = params || {};
@ -321,7 +326,13 @@ const OrdersPage: React.FC = () => {
const pageSize = p.pageSize;
const date = p.date;
const status = p.status;
const { current: _c, pageSize: _ps, date: _d, status: _s, ...rest } = p;
const {
current: _c,
pageSize: _ps,
date: _d,
status: _s,
...rest
} = p;
const where: Record<string, any> = { ...(filter || {}), ...rest };
if (status && status !== 'all') {
where.status = status;
@ -345,7 +356,7 @@ const OrdersPage: React.FC = () => {
page_size: pageSize,
where,
...(orderObj ? { order: orderObj } : {}),
}
},
});
if (!response.success) {
@ -379,7 +390,7 @@ const OrdersPage: React.FC = () => {
const { status: _status, ...baseWhere } = where;
// 并发请求各状态的总数,对站点接口不支持的状态使用0
const results = await Promise.all(
statusKeys.map(async key => {
statusKeys.map(async (key) => {
// 将前端退款状态映射为站点接口可能识别的原始状态
const mapToRawStatus: Record<string, string> = {
refund_requested: 'return-requested',
@ -388,7 +399,10 @@ const OrdersPage: React.FC = () => {
};
const rawStatus = mapToRawStatus[key] || key;
// 对扩展状态直接返回0,减少不必要的请求
const unsupported = ['after_sale_pending', 'pending_reshipment'];
const unsupported = [
'after_sale_pending',
'pending_reshipment',
];
if (unsupported.includes(key)) {
return { status: key, count: 0 };
}
@ -406,7 +420,7 @@ const OrdersPage: React.FC = () => {
// 请求失败时该状态数量记为0
return { status: key, count: 0 };
}
})
}),
);
setCount(results);
} catch (e) {
@ -418,15 +432,14 @@ const OrdersPage: React.FC = () => {
return {
total: data?.total || 0,
data: data?.items || [],
success: true
success: true,
};
}
return {
data: [],
success: false
success: false,
};
}}
/>
</PageContainer>
);

View File

@ -1,24 +1,24 @@
import { PRODUCT_STATUS_ENUM, PRODUCT_STOCK_STATUS_ENUM } from '@/constants';
import { request } from '@umijs/max';
import { useParams } from '@umijs/max';
import { DeleteFilled, LinkOutlined } from '@ant-design/icons';
import {
ActionType,
PageContainer,
ProColumns,
ProTable,
} from '@ant-design/pro-components';
import { request, useParams } from '@umijs/max';
import { App, Button, Divider, Popconfirm, Tag } from 'antd';
import { DeleteFilled, LinkOutlined } from '@ant-design/icons';
import React, { useEffect, useRef, useState } from 'react';
import { ErpProductBindModal } from '../components/Product/ErpProductBindModal';
import {
BatchDeleteProducts,
BatchEditProducts,
CreateProduct,
ImportCsv,
SetComponent,
UpdateForm,
UpdateStatus,
UpdateVaritation,
BatchDeleteProducts,
CreateProduct,
} from '../components/Product/Forms';
import { TagConfig } from '../components/Product/utils';
const ProductsPage: React.FC = () => {
@ -67,11 +67,22 @@ const ProductsPage: React.FC = () => {
if (!dict) {
return [];
}
const res = await request('/dict/items', { params: { dictId: dict.id } });
const res = await request('/dict/items', {
params: { dictId: dict.id },
});
return res.map((item: any) => item.name);
};
const [brands, fruits, mints, flavors, strengths, sizes, humidities, categories] = await Promise.all([
const [
brands,
fruits,
mints,
flavors,
strengths,
sizes,
humidities,
categories,
] = await Promise.all([
getItems('brand'),
getItems('fruit'),
getItems('mint'),
@ -90,7 +101,7 @@ const ProductsPage: React.FC = () => {
strengths,
sizes,
humidities,
categories
categories,
});
} catch (error) {
console.error('Failed to fetch configs:', error);
@ -100,7 +111,7 @@ const ProductsPage: React.FC = () => {
fetchAllConfigs();
}, []);
const columns: ProColumns<API.UnifiedProductDTO>[] = [
const columns: ProColumns<any>[] = [
{
// ID
title: 'ID',
@ -110,7 +121,7 @@ const ProductsPage: React.FC = () => {
copyable: true,
render: (_, record) => {
return record?.id ?? '-';
}
},
},
{
// sku
@ -123,6 +134,11 @@ const ProductsPage: React.FC = () => {
title: '名称',
dataIndex: 'name',
},
{
// 产品类型
title: '产品类型',
dataIndex: 'type',
},
{
// 产品状态
title: '产品状态',
@ -130,11 +146,7 @@ const ProductsPage: React.FC = () => {
valueType: 'select',
valueEnum: PRODUCT_STATUS_ENUM,
},
{
// 产品类型
title: '产品类型',
dataIndex: 'type',
},
{
// 库存状态
title: '库存状态',
@ -148,6 +160,43 @@ const ProductsPage: React.FC = () => {
dataIndex: 'stock_quantity',
hideInSearch: true,
},
{
// ERP产品信息
title: 'ERP产品',
dataIndex: 'erpProduct',
hideInSearch: true,
width: 200,
render: (_, record) => {
if (record.erpProduct) {
return (
<div
style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
>
<div>
<strong>SKU:</strong> {record.erpProduct.sku}
</div>
<div>
<strong>:</strong> {record.erpProduct.name}
</div>
{record.erpProduct.nameCn && (
<div>
<strong>:</strong> {record.erpProduct.nameCn}
</div>
)}
{record.erpProduct.category && (
<div>
<strong>:</strong> {record.erpProduct.category.name}
</div>
)}
<div>
<strong>:</strong> {record.erpProduct.stock_quantity ?? '-'}
</div>
</div>
);
}
return <Tag color="orange"></Tag>;
},
},
{
// 图片
title: '图片',
@ -172,29 +221,7 @@ const ProductsPage: React.FC = () => {
dataIndex: 'sale_price',
hideInSearch: true,
},
{
// 标签
title: '标签',
dataIndex: 'tags',
hideInSearch: true,
width: 250,
render: (_, record) => {
// 检查 record.tags 是否存在并且是一个数组
if (record.tags && Array.isArray(record.tags)) {
// 遍历 tags 数组并为每个 tag 对象渲染一个 Tag 组件
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{record.tags.map((tag: any) => (
// 使用 tag.name 作为 key, 因为 tag.id 可能是对象, 会导致 React key 错误
<Tag key={tag.name}>{tag.name}</Tag>
))}
</div>
);
}
// 如果 record.tags 不是一个有效的数组,则不渲染任何内容
return null;
},
},
{
// 分类
title: '分类',
@ -228,10 +255,13 @@ const ProductsPage: React.FC = () => {
// 检查 record.attributes 是否存在并且是一个数组
if (record.attributes && Array.isArray(record.attributes)) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}
>
{(record.attributes as any[]).map((attr: any) => (
<div key={attr.name}>
<strong>{attr.name}:</strong> {Array.isArray(attr.options) ? attr.options.join(', ') : ''}
<strong>{attr.name}:</strong>{' '}
{Array.isArray(attr.options) ? attr.options.join(', ') : ''}
</div>
))}
</div>
@ -240,6 +270,29 @@ const ProductsPage: React.FC = () => {
return null;
},
},
{
// 标签
title: '标签',
dataIndex: 'tags',
hideInSearch: true,
width: 250,
render: (_, record) => {
// 检查 record.tags 是否存在并且是一个数组
if (record.tags && Array.isArray(record.tags)) {
// 遍历 tags 数组并为每个 tag 对象渲染一个 Tag 组件
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
{record.tags.map((tag: any) => (
// 使用 tag.name 作为 key, 因为 tag.id 可能是对象, 会导致 React key 错误
<Tag key={tag.name}>{tag.name}</Tag>
))}
</div>
);
}
// 如果 record.tags 不是一个有效的数组,则不渲染任何内容
return null;
},
},
{
// 创建时间
title: '创建时间',
@ -263,16 +316,38 @@ const ProductsPage: React.FC = () => {
width: '200',
render: (_, record) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<UpdateForm tableRef={actionRef} values={record} config={config} siteId={siteId} />
<UpdateForm
tableRef={actionRef}
values={record}
config={config}
siteId={siteId}
/>
<UpdateStatus tableRef={actionRef} values={record} siteId={siteId} />
{siteId && (
<ErpProductBindModal
trigger={
<Button
type="link"
title={record.erpProduct ? '换绑ERP产品' : '绑定ERP产品'}
>
{record.erpProduct ? '换绑' : '绑定'}
</Button>
}
siteProduct={record}
siteId={siteId}
onBindSuccess={() => {
actionRef.current?.reload();
}}
/>
)}
<Button
type="link"
title="店铺链接"
icon={<LinkOutlined />}
disabled={!record.frontendUrl}
disabled={!record.permalink}
onClick={() => {
if (record.frontendUrl) {
window.open(record.frontendUrl, '_blank', 'noopener,noreferrer');
if (record.permalink) {
window.open(record.permalink, '_blank', 'noopener,noreferrer');
} else {
message.warning('未能生成店铺链接');
}
@ -284,7 +359,9 @@ const ProductsPage: React.FC = () => {
description="确认删除?"
onConfirm={async () => {
try {
await request(`/site-api/${siteId}/products/${record.id}`, { method: 'DELETE' });
await request(`/site-api/${siteId}/products/${record.id}`, {
method: 'DELETE',
});
message.success('删除成功');
actionRef.current?.reload();
} catch (e: any) {
@ -295,11 +372,11 @@ const ProductsPage: React.FC = () => {
<Button type="link" danger title="删除" icon={<DeleteFilled />} />
</Popconfirm>
{record.type === 'simple' && record.sku ? (
<SetComponent
tableRef={actionRef}
values={record}
isProduct={true}
/>
<SetComponent
tableRef={actionRef}
values={record}
isProduct={true}
/>
) : (
<></>
)}
@ -315,7 +392,7 @@ const ProductsPage: React.FC = () => {
<ProTable<API.UnifiedProductDTO>
scroll={{ x: 'max-content' }}
pagination={{
pageSizeOptions: ['10', '20', '50', '100', '1000','2000'],
pageSizeOptions: ['10', '20', '50', '100', '1000', '2000'],
showSizeChanger: true,
defaultPageSize: 10,
}}
@ -346,10 +423,15 @@ const ProductsPage: React.FC = () => {
page,
per_page: pageSize,
...where,
...(orderObj ? { sortField: Object.keys(orderObj)[0], sortOrder: Object.values(orderObj)[0] } : {}),
}
...(orderObj
? {
sortField: Object.keys(orderObj)[0],
sortOrder: Object.values(orderObj)[0],
}
: {}),
},
});
if (!response.success) {
message.error(response.message || '获取列表失败');
return {
@ -385,38 +467,59 @@ const ProductsPage: React.FC = () => {
siteId={siteId}
/>,
<ImportCsv tableRef={actionRef} siteId={siteId} />,
<Button onClick={async () => {
const idsParam = selectedRowKeys.length ? (selectedRowKeys as any[]).join(',') : undefined;
const res = await request(`/site-api/${siteId}/products/export`, { params: { ids: idsParam } });
if (res?.success && res?.data?.csv) {
const blob = new Blob([res.data.csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'products.csv';
a.click();
URL.revokeObjectURL(url);
}
}}></Button>,
<Button
onClick={async () => {
const idsParam = selectedRowKeys.length
? (selectedRowKeys as any[]).join(',')
: undefined;
const res = await request(`/site-api/${siteId}/products/export`, {
params: { ids: idsParam },
});
if (res?.success && res?.data?.csv) {
const blob = new Blob([res.data.csv], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'products.csv';
a.click();
URL.revokeObjectURL(url);
}
}}
>
</Button>,
]}
expandable={{
rowExpandable: (record) => record.type === 'variable',
expandedRowRender: (record) => {
const productExternalId = (record as any).externalProductId || (record as any).external_product_id || record.id;
const productExternalId =
(record as any).externalProductId ||
(record as any).external_product_id ||
record.id;
const innerColumns: ProColumns<any>[] = [
{
title: 'ID',
dataIndex: 'id',
hideInSearch: true,
{
title: 'ID',
dataIndex: 'id',
hideInSearch: true,
width: 120,
render: (_, row) => {
return row?.id ?? '-';
}
},
},
{ title: '变体名', dataIndex: 'name' },
{ title: 'sku', dataIndex: 'sku' },
{ title: '常规价格', dataIndex: 'regular_price', hideInSearch: true },
{ title: '销售价格', dataIndex: 'sale_price', hideInSearch: true },
{
title: '常规价格',
dataIndex: 'regular_price',
hideInSearch: true,
},
{
title: '销售价格',
dataIndex: 'sale_price',
hideInSearch: true,
},
{
title: 'Attributes',
dataIndex: 'attributes',
@ -425,7 +528,13 @@ const ProductsPage: React.FC = () => {
// 检查 row.attributes 是否存在并且是一个数组
if (row.attributes && Array.isArray(row.attributes)) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
}}
>
{(row.attributes as any[]).map((attr: any) => (
<div key={attr.name}>
<strong>{attr.name}:</strong> {attr.option}
@ -443,11 +552,20 @@ const ProductsPage: React.FC = () => {
valueType: 'option',
render: (_, row) => (
<>
<UpdateVaritation tableRef={actionRef} values={row} siteId={siteId} productId={productExternalId} />
<UpdateVaritation
tableRef={actionRef}
values={row}
siteId={siteId}
productId={productExternalId}
/>
{row.sku ? (
<>
<Divider type="vertical" />
<SetComponent tableRef={actionRef} values={row} isProduct={false} />
<SetComponent
tableRef={actionRef}
values={row}
isProduct={false}
/>
</>
) : (
<></>

View File

@ -1,4 +1,3 @@
import React from 'react';
interface ReviewFormProps {
@ -9,7 +8,13 @@ interface ReviewFormProps {
onSuccess: () => void;
}
const ReviewForm: React.FC<ReviewFormProps> = ({ open, editing, siteId, onClose, onSuccess }) => {
const ReviewForm: React.FC<ReviewFormProps> = ({
open,
editing,
siteId,
onClose,
onSuccess,
}) => {
// // 这是一个临时的占位符组件
// // 你可以在这里实现表单逻辑
if (!open) {

View File

@ -1,9 +1,17 @@
import React, { useRef, useState } from 'react';
import { ActionType, ProTable, ProColumns, ProCard } from '@ant-design/pro-components';
import { Button, Popconfirm, message, Space } from 'antd';
import { siteapicontrollerGetreviews, siteapicontrollerDeletereview } from '@/servers/api/siteApi';
import ReviewForm from './ReviewForm';
import {
siteapicontrollerDeletereview,
siteapicontrollerGetreviews,
} from '@/servers/api/siteApi';
import {
ActionType,
ProCard,
ProColumns,
ProTable,
} from '@ant-design/pro-components';
import { useParams } from '@umijs/max';
import { Button, message, Popconfirm, Space } from 'antd';
import React, { useRef, useState } from 'react';
import ReviewForm from './ReviewForm';
const ReviewsPage: React.FC = () => {
const params = useParams();
@ -31,26 +39,40 @@ const ReviewsPage: React.FC = () => {
width: 150,
render: (_, record) => (
<Space>
<Button type="link" style={{padding:0}} onClick={() => {
setEditing(record);
setOpen(true);
}}></Button>
<Popconfirm title="确定删除吗?" onConfirm={async () => {
if (record.id) {
try {
const response = await siteapicontrollerDeletereview({ siteId, id: String(record.id) });
if (response.success) {
message.success('删除成功');
actionRef.current?.reload();
} else {
<Button
type="link"
style={{ padding: 0 }}
onClick={() => {
setEditing(record);
setOpen(true);
}}
>
</Button>
<Popconfirm
title="确定删除吗?"
onConfirm={async () => {
if (record.id) {
try {
const response = await siteapicontrollerDeletereview({
siteId,
id: String(record.id),
});
if (response.success) {
message.success('删除成功');
actionRef.current?.reload();
} else {
message.error('删除失败');
}
} catch (error) {
message.error('删除失败');
}
} catch (error) {
message.error('删除失败');
}
}
}}>
<Button type='link' danger></Button>
}}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
@ -63,7 +85,12 @@ const ReviewsPage: React.FC = () => {
columns={columns}
actionRef={actionRef}
request={async (params) => {
const response = await siteapicontrollerGetreviews({ ...params, siteId, page: params.current, per_page: params.pageSize });
const response = await siteapicontrollerGetreviews({
...params,
siteId,
page: params.current,
per_page: params.pageSize,
});
return {
data: response.data.items,
success: true,
@ -76,10 +103,13 @@ const ReviewsPage: React.FC = () => {
}}
headerTitle="评论列表"
toolBarRender={() => [
<Button type="primary" onClick={() => {
setEditing(null);
setOpen(true);
}}>
<Button
type="primary"
onClick={() => {
setEditing(null);
setOpen(true);
}}
>
</Button>,
]}

View File

@ -1,16 +1,13 @@
import { sitecontrollerAll } from '@/servers/api/site';
import {} from '@/servers/api/subscription';
import { DeleteFilled, EditOutlined, PlusOutlined } from '@ant-design/icons';
import {
ActionType,
DrawerForm,
PageContainer,
ProColumns,
ProFormSelect,
ProTable,
} from '@ant-design/pro-components';
import { useParams } from '@umijs/max';
import { App, Button, Drawer, List, Popconfirm, Space, Tag } from 'antd';
import { DeleteFilled, EditOutlined, PlusOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import React, { useRef, useState } from 'react';
import { request } from 'umi';
@ -116,8 +113,16 @@ const SubscriptionsPage: React.FC = () => {
valueType: 'option',
render: (_, row) => (
<Space>
<Button type="link" title="编辑" icon={<EditOutlined />} onClick={() => setEditing(row)} />
<Popconfirm title="确定删除?" onConfirm={() => message.info('订阅删除未实现')}>
<Button
type="link"
title="编辑"
icon={<EditOutlined />}
onClick={() => setEditing(row)}
/>
<Popconfirm
title="确定删除?"
onConfirm={() => message.info('订阅删除未实现')}
>
<Button type="link" danger title="删除" icon={<DeleteFilled />} />
</Popconfirm>
</Space>
@ -142,9 +147,9 @@ const SubscriptionsPage: React.FC = () => {
...params,
page: params.current,
per_page: params.pageSize,
}
},
});
if (!response.success) {
message.error(response.message || '获取订阅列表失败');
return {
@ -165,15 +170,29 @@ const SubscriptionsPage: React.FC = () => {
// 工具栏:订阅同步入口
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
toolBarRender={() => [
<Button type="primary" title="新增" icon={<PlusOutlined />} onClick={() => message.info('订阅新增未实现')} />,
<Button title="批量编辑" icon={<EditOutlined />} onClick={() => message.info('批量编辑未实现')} />,
<Button
type="primary"
title="新增"
icon={<PlusOutlined />}
onClick={() => message.info('订阅新增未实现')}
/>,
<Button
title="批量编辑"
icon={<EditOutlined />}
onClick={() => message.info('批量编辑未实现')}
/>,
<Button
title="批量导出"
onClick={async () => {
if (!siteId) return;
const res = await request(`/site-api/${siteId}/subscriptions/export`, { params: {} });
const res = await request(
`/site-api/${siteId}/subscriptions/export`,
{ params: {} },
);
if (res?.success && res?.data?.csv) {
const blob = new Blob([res.data.csv], { type: 'text/csv;charset=utf-8;' });
const blob = new Blob([res.data.csv], {
type: 'text/csv;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@ -185,8 +204,14 @@ const SubscriptionsPage: React.FC = () => {
}
}}
/>,
<Button title="批量删除" danger icon={<DeleteFilled />} onClick={() => message.info('订阅删除未实现')} />
]} />
<Button
title="批量删除"
danger
icon={<DeleteFilled />}
onClick={() => message.info('订阅删除未实现')}
/>,
]}
/>
<Drawer
open={drawerOpen}
title={drawerTitle}

View File

@ -1,22 +1,13 @@
import InternationalPhoneInput from '@/components/InternationalPhoneInput';
import { ORDER_STATUS_ENUM } from '@/constants';
import {
logisticscontrollerCreateshipment,
logisticscontrollerDelshipment,
logisticscontrollerGetshipmentfee,
logisticscontrollerGetshippingaddresslist,
} from '@/servers/api/logistics';
import {
productcontrollerSearchproducts,
} from '@/servers/api/product';
import { productcontrollerSearchproducts } from '@/servers/api/product';
import { stockcontrollerGetallstockpoints } from '@/servers/api/stock';
import { formatShipmentState, formatSource } from '@/utils/format';
import {
CodeSandboxOutlined,
CopyOutlined,
DeleteFilled,
EditOutlined,
FileDoneOutlined,
PlusOutlined,
TagsOutlined,
} from '@ant-design/icons';
@ -25,36 +16,20 @@ import {
DrawerForm,
ModalForm,
ProColumns,
ProDescriptions,
ProForm,
ProFormDatePicker,
ProFormDependency,
ProFormDigit,
ProFormInstance,
ProFormItem,
ProFormList,
ProFormRadio,
ProFormSelect,
ProFormText,
ProFormTextArea,
ProTable,
} from '@ant-design/pro-components';
import { request } from '@umijs/max';
import {
App,
Button,
Card,
Col,
Divider,
Drawer,
Empty,
Popconfirm,
Radio,
Row,
} from 'antd';
import { App, Button, Col, Divider, Row } from 'antd';
import dayjs from 'dayjs';
import React, { useRef, useState } from 'react';
import RelatedOrders from '@/pages/Subscription/Orders/RelatedOrders';
const region = {
AB: 'Alberta',
@ -88,24 +63,28 @@ export const OrderNote: React.FC<{
}
onFinish={async (values: any) => {
if (!siteId) {
message.error('缺少站点ID');
return false;
message.error('缺少站点ID');
return false;
}
try {
// Use new API for creating note
const { success, data } = await request(`/site-api/${siteId}/orders/${id}/notes`, {
method: 'POST',
data: {
...values,
orderId: id, // API might not need this in body if in URL, but keeping for compatibility if adapter needs it
}
});
const { success, data } = await request(
`/site-api/${siteId}/orders/${id}/notes`,
{
method: 'POST',
data: {
...values,
orderId: id, // API might not need this in body if in URL, but keeping for compatibility if adapter needs it
},
},
);
// Check success based on response structure
if (success === false) { // Assuming response.util returns success: boolean
throw new Error('提交失败');
if (success === false) {
// Assuming response.util returns success: boolean
throw new Error('提交失败');
}
descRef?.current?.reload();
message.success('提交成功');
return true;
@ -255,27 +234,26 @@ export const Shipping: React.FC<{
request={async () => {
if (!siteId) return {};
// Use site-api to get order detail
const { data, success } = await request(`/site-api/${siteId}/orders/${id}`);
const { data, success } = await request(
`/site-api/${siteId}/orders/${id}`,
);
if (!success || !data) return {};
// Use 'sales' which I added to DTO
const sales = data.sales || [];
// Logic for merging duplicate products
const mergedSales = sales.reduce(
(acc: any[], cur: any) => {
let idx = acc.findIndex((v: any) => v.productId === cur.productId);
if (idx === -1) {
acc.push({ ...cur }); // clone
} else {
acc[idx].quantity += cur.quantity;
}
return acc;
},
[],
);
const mergedSales = sales.reduce((acc: any[], cur: any) => {
let idx = acc.findIndex((v: any) => v.productId === cur.productId);
if (idx === -1) {
acc.push({ ...cur }); // clone
} else {
acc[idx].quantity += cur.quantity;
}
return acc;
}, []);
// Update data.sales
data.sales = mergedSales;
@ -359,7 +337,7 @@ export const Shipping: React.FC<{
// Warning: This uses local logistics controller which might expect local ID.
// We are passing 'id' which is now External ID (if we fetch via site-api).
// If logistics module doesn't handle external ID, this will fail.
details.origin.email_addresses =
details.origin.email_addresses.split(',');
details.destination.email_addresses =
@ -429,7 +407,7 @@ export const Shipping: React.FC<{
// If we use site-api, we should search site-api.
// But site-api doesn't have order search by number yet.
// I'll leave it empty/disabled for now.
options={[]}
options={[]}
disabled
placeholder="暂不支持合并外部订单发货"
/>
@ -450,10 +428,7 @@ export const Shipping: React.FC<{
</ProFormList>
</Col>
<Col span={12}>
<ProFormList
label="发货产品"
name="sales"
>
<ProFormList label="发货产品" name="sales">
<ProForm.Group>
<ProFormSelect
params={{ options }}
@ -548,11 +523,11 @@ export const Shipping: React.FC<{
name={['details', 'origin', 'name']}
rules={[{ required: true, message: '请输入公司名称' }]}
/>
{/* Simplified for brevity - assume standard fields remain */}
{/* Simplified for brevity - assume standard fields remain */}
</ProForm.Group>
</Col>
</Row>
{/* ... Packaging fields ... */}
{/* ... Packaging fields ... */}
</ModalForm>
);
};
@ -588,23 +563,26 @@ export const CreateOrder: React.FC<{
}}
onFinish={async ({ items, details, ...data }) => {
if (!siteId) {
message.error('缺少站点ID');
return false;
message.error('缺少站点ID');
return false;
}
try {
// Use site-api to create order
const { success, message: errMsg } = await request(`/site-api/${siteId}/orders`, {
method: 'POST',
data: {
...data,
customer_email: data?.billing?.email,
billing_phone: data?.billing?.phone,
// map other fields if needed for Adapter
}
});
const { success, message: errMsg } = await request(
`/site-api/${siteId}/orders`,
{
method: 'POST',
data: {
...data,
customer_email: data?.billing?.email,
billing_phone: data?.billing?.phone,
// map other fields if needed for Adapter
},
},
);
if (success === false) throw new Error(errMsg); // Check success
message.success('创建成功');
tableRef?.current?.reload();
@ -621,7 +599,7 @@ export const CreateOrder: React.FC<{
}}
>
{/* ... Form fields ... same as before */}
<ProFormDigit
<ProFormDigit
label="金额"
name="total"
rules={[{ required: true, message: '请输入金额' }]}
@ -654,22 +632,27 @@ export const BatchEditOrders: React.FC<{
modalProps={{ destroyOnHidden: true }}
onFinish={async (values) => {
if (!siteId) return false;
let ok = 0, fail = 0;
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;
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++;
}
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();
@ -699,126 +682,133 @@ export const EditOrder: React.FC<{
return (
<DrawerForm
formRef={formRef}
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}`);
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={() => []}
formRef={formRef}
title="编辑订单"
trigger={
<Button
type="primary"
size="small"
icon={<EditOutlined />}
onClick={() => setActiveLine(record.id)}
>
<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 />
</Button>
}
drawerProps={{
destroyOnHidden: true,
width: '60vw',
}}
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={() => []}
>
<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,177 @@
import { productcontrollerSearchproducts } from '@/servers/api/product';
import { ModalForm, ProTable } from '@ant-design/pro-components';
import { Form, message } from 'antd';
import React, { useState } from 'react';
interface ErpProductBindModalProps {
trigger: React.ReactNode;
siteProduct: any;
siteId: string;
onBindSuccess?: () => void;
}
export const ErpProductBindModal: React.FC<ErpProductBindModalProps> = ({
trigger,
siteProduct,
siteId,
onBindSuccess,
}) => {
const [form] = Form.useForm();
const [selectedProduct, setSelectedProduct] = useState<any>(null);
const handleBind = async (values: any) => {
if (!selectedProduct) {
message.error('请选择一个ERP产品');
return false;
}
try {
// 调用绑定API
const response = await fetch(
`/api/site-api/${siteId}/products/${siteProduct.id}/bind-erp`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
erpProductId: selectedProduct.id,
siteSku: siteProduct.sku,
}),
},
);
const result = await response.json();
if (result.success) {
message.success('ERP产品绑定成功');
onBindSuccess?.();
return true;
} else {
message.error(result.message || '绑定失败');
return false;
}
} catch (error) {
message.error('绑定请求失败');
return false;
}
};
return (
<ModalForm
title="绑定ERP产品"
trigger={trigger}
form={form}
modalProps={{
destroyOnClose: true,
width: 800,
}}
onFinish={handleBind}
>
<div style={{ marginBottom: 16 }}>
<strong></strong>
<div>SKU: {siteProduct.sku}</div>
<div>: {siteProduct.name}</div>
{siteProduct.erpProduct && (
<div style={{ color: '#ff4d4f' }}>
ERP产品{siteProduct.erpProduct.sku} -{' '}
{siteProduct.erpProduct.name}
</div>
)}
</div>
<ProTable
rowKey="id"
search={{
labelWidth: 'auto',
}}
request={async (params) => {
const response = await productcontrollerSearchproducts({
keyword: params.keyword,
page: params.current,
per_page: params.pageSize,
});
if (response.success) {
return {
data: response.data.items,
total: response.data.total,
success: true,
};
}
return {
data: [],
total: 0,
success: false,
};
}}
columns={[
{
title: 'ID',
dataIndex: 'id',
width: 80,
},
{
title: 'SKU',
dataIndex: 'sku',
copyable: true,
},
{
title: '产品名称',
dataIndex: 'name',
ellipsis: true,
},
{
title: '中文名称',
dataIndex: 'nameCn',
ellipsis: true,
},
{
title: '分类',
dataIndex: ['category', 'name'],
ellipsis: true,
},
{
title: '价格',
dataIndex: 'price',
width: 100,
render: (text) => `¥${text}`,
},
]}
rowSelection={{
type: 'radio',
onChange: (_, selectedRows) => {
setSelectedProduct(selectedRows[0]);
},
}}
pagination={{
pageSize: 10,
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
toolBarRender={false}
options={false}
scroll={{ y: 400 }}
/>
{selectedProduct && (
<div
style={{
marginTop: 16,
padding: 12,
backgroundColor: '#f6ffed',
border: '1px solid #b7eb8f',
}}
>
<strong></strong>
<div>SKU: {selectedProduct.sku}</div>
<div>: {selectedProduct.name}</div>
{selectedProduct.nameCn && (
<div>: {selectedProduct.nameCn}</div>
)}
</div>
)}
</ModalForm>
);
};

View File

@ -1,10 +1,5 @@
import { PRODUCT_STATUS_ENUM, PRODUCT_STOCK_STATUS_ENUM } from '@/constants';
import {
productcontrollerProductbysku,
productcontrollerSearchproducts,
} from '@/servers/api/product';
import { sitecontrollerAll } from '@/servers/api/site';
import { EditOutlined, PlusOutlined, DeleteFilled } from '@ant-design/icons';
import { DeleteFilled, EditOutlined, PlusOutlined } from '@ant-design/icons';
import {
ActionType,
DrawerForm,
@ -14,7 +9,6 @@ import {
ProFormList,
ProFormSelect,
ProFormText,
ProFormUploadButton,
ProFormTextArea,
} from '@ant-design/pro-components';
import { request } from '@umijs/max';
@ -33,15 +27,19 @@ export const CreateProduct: React.FC<{
<DrawerForm
title="新增产品"
form={form}
trigger={<Button type="primary" title="新增产品" icon={<PlusOutlined />}></Button>}
trigger={
<Button type="primary" title="新增产品" icon={<PlusOutlined />}>
</Button>
}
autoFocusFirstInput
drawerProps={{
destroyOnHidden: true,
}}
onFinish={async (values) => {
if (!siteId) {
message.error('缺少站点ID');
return false;
message.error('缺少站点ID');
return false;
}
try {
// 将数字字段转换为字符串以匹配DTO
@ -50,14 +48,17 @@ export const CreateProduct: React.FC<{
type: values.type || 'simple',
regular_price: values.regular_price?.toString() || '',
sale_price: values.sale_price?.toString() || '',
price: values.sale_price?.toString() || values.regular_price?.toString() || '',
price:
values.sale_price?.toString() ||
values.regular_price?.toString() ||
'',
};
await request(`/site-api/${siteId}/products`, {
method: 'POST',
data: productData,
});
message.success('创建成功');
tableRef.current?.reload();
return true;
@ -87,16 +88,8 @@ export const CreateProduct: React.FC<{
width="lg"
rules={[{ required: true, message: '请输入SKU' }]}
/>
<ProFormTextArea
name="description"
label="描述"
width="lg"
/>
<ProFormTextArea
name="short_description"
label="简短描述"
width="lg"
/>
<ProFormTextArea name="description" label="描述" width="lg" />
<ProFormTextArea name="short_description" label="简短描述" width="lg" />
<ProFormDigit
name="regular_price"
label="常规价格"
@ -116,17 +109,17 @@ export const CreateProduct: React.FC<{
fieldProps={{ precision: 0 }}
/>
<ProFormSelect
name="status"
label="产品状态"
width="md"
valueEnum={PRODUCT_STATUS_ENUM}
initialValue="publish"
name="status"
label="产品状态"
width="md"
valueEnum={PRODUCT_STATUS_ENUM}
initialValue="publish"
/>
<ProFormSelect
name="stock_status"
label="库存状态"
width="md"
valueEnum={PRODUCT_STOCK_STATUS_ENUM}
name="stock_status"
label="库存状态"
width="md"
valueEnum={PRODUCT_STOCK_STATUS_ENUM}
/>
</ProForm.Group>
<Divider />
@ -135,12 +128,12 @@ export const CreateProduct: React.FC<{
label="产品图片"
initialValue={[{}]}
creatorButtonProps={{
creatorButtonText: '添加图片',
creatorButtonText: '添加图片',
}}
>
<ProForm.Group>
<ProFormText name="src" label="图片URL" width="lg" />
<ProFormText name="alt" label="替代文本" width="md" />
<ProFormText name="src" label="图片URL" width="lg" />
<ProFormText name="alt" label="替代文本" width="md" />
</ProForm.Group>
</ProFormList>
<Divider />
@ -149,38 +142,38 @@ export const CreateProduct: React.FC<{
label="产品属性"
initialValue={[]}
creatorButtonProps={{
creatorButtonText: '添加属性',
creatorButtonText: '添加属性',
}}
>
<ProForm.Group>
<ProFormText name="name" label="属性名称" width="md" />
<ProFormSelect
name="options"
label="选项"
width="md"
mode="tags"
placeholder="输入选项并回车"
/>
<ProFormSelect
name="visible"
label="可见性"
width="xs"
options={[
{ label: '可见', value: true },
{ label: '隐藏', value: false },
]}
initialValue={true}
/>
<ProFormSelect
name="variation"
label="用于变体"
width="xs"
options={[
{ label: '是', value: true },
{ label: '否', value: false },
]}
initialValue={false}
/>
<ProFormText name="name" label="属性名称" width="md" />
<ProFormSelect
name="options"
label="选项"
width="md"
mode="tags"
placeholder="输入选项并回车"
/>
<ProFormSelect
name="visible"
label="可见性"
width="xs"
options={[
{ label: '可见', value: true },
{ label: '隐藏', value: false },
]}
initialValue={true}
/>
<ProFormSelect
name="variation"
label="用于变体"
width="xs"
options={[
{ label: '是', value: true },
{ label: '否', value: false },
]}
initialValue={false}
/>
</ProForm.Group>
</ProFormList>
</DrawerForm>
@ -193,13 +186,15 @@ export const UpdateStatus: React.FC<{
siteId?: string;
}> = ({ tableRef, values: initialValues, siteId }) => {
const { message } = App.useApp();
// 转换初始值,将字符串价格转换为数字以便编辑
const formValues = {
...initialValues,
stock_quantity: initialValues.stock_quantity ? parseInt(initialValues.stock_quantity) : 0,
stock_quantity: initialValues.stock_quantity
? parseInt(initialValues.stock_quantity)
: 0,
};
return (
<DrawerForm<{
status: any;
@ -209,7 +204,9 @@ export const UpdateStatus: React.FC<{
title="修改产品状态"
initialValues={formValues}
trigger={
<Button type="link" title="修改状态" icon={<EditOutlined />}></Button>
<Button type="link" title="修改状态" icon={<EditOutlined />}>
</Button>
}
autoFocusFirstInput
drawerProps={{
@ -217,17 +214,17 @@ export const UpdateStatus: React.FC<{
}}
onFinish={async (values) => {
if (!siteId) {
message.error('缺少站点ID');
return false;
message.error('缺少站点ID');
return false;
}
try {
await request(`/site-api/${siteId}/products/${initialValues.id}`, {
method: 'PUT',
data: {
status: values.status,
stock_status: values.stock_status,
stock_quantity: values.stock_quantity,
}
status: values.status,
stock_status: values.stock_status,
stock_quantity: values.stock_quantity,
},
});
message.success('状态更新成功');
tableRef.current?.reload();
@ -276,8 +273,12 @@ export const UpdateForm: React.FC<{
...initialValues,
categories: initialValues.categories?.map((c: any) => c.name) || [],
tags: initialValues.tags?.map((t: any) => t.name) || [],
regular_price: initialValues.regular_price ? parseFloat(initialValues.regular_price) : 0,
sale_price: initialValues.sale_price ? parseFloat(initialValues.sale_price) : 0,
regular_price: initialValues.regular_price
? parseFloat(initialValues.regular_price)
: 0,
sale_price: initialValues.sale_price
? parseFloat(initialValues.sale_price)
: 0,
};
const handleAutoGenerateTags = () => {
@ -289,7 +290,7 @@ export const UpdateForm: React.FC<{
const name = initialValues.name || '';
const generatedTagsString = computeTags(name, sku, config);
const generatedTags = generatedTagsString.split(', ').filter(t => t);
const generatedTags = generatedTagsString.split(', ').filter((t) => t);
if (generatedTags.length > 0) {
const currentTags = form.getFieldValue('tags') || [];
@ -307,7 +308,9 @@ export const UpdateForm: React.FC<{
form={form}
initialValues={formValues}
trigger={
<Button type="link" title="编辑详情" icon={<EditOutlined />}></Button>
<Button type="link" title="编辑详情" icon={<EditOutlined />}>
</Button>
}
autoFocusFirstInput
drawerProps={{
@ -315,8 +318,8 @@ export const UpdateForm: React.FC<{
}}
onFinish={async (values) => {
if (!siteId) {
message.error('缺少站点ID');
return false;
message.error('缺少站点ID');
return false;
}
try {
// 将数字字段转换为字符串以匹配DTO
@ -324,12 +327,15 @@ export const UpdateForm: React.FC<{
...values,
regular_price: values.regular_price?.toString() || '',
sale_price: values.sale_price?.toString() || '',
price: values.sale_price?.toString() || values.regular_price?.toString() || '',
price:
values.sale_price?.toString() ||
values.regular_price?.toString() ||
'',
};
await request(`/site-api/${siteId}/products/${initialValues.id}`, {
method: 'PUT',
data: updateData
data: updateData,
});
message.success('提交成功');
tableRef.current?.reload();
@ -341,10 +347,10 @@ export const UpdateForm: React.FC<{
}}
>
<ProForm.Group>
<ProFormText
label="产品名称"
width="lg"
name="name"
<ProFormText
label="产品名称"
width="lg"
name="name"
rules={[{ required: true, message: '请输入产品名称' }]}
/>
<ProFormText
@ -355,16 +361,8 @@ export const UpdateForm: React.FC<{
placeholder="请输入SKU"
rules={[{ required: true, message: '请输入SKU' }]}
/>
<ProFormTextArea
name="short_description"
label="简短描述"
width="lg"
/>
<ProFormTextArea
name="description"
label="描述"
width="lg"
/>
<ProFormTextArea name="short_description" label="简短描述" width="lg" />
<ProFormTextArea name="description" label="描述" width="lg" />
{initialValues.type === 'simple' ? (
<>
@ -391,20 +389,20 @@ export const UpdateForm: React.FC<{
fieldProps={{ precision: 0 }}
/>
<ProFormSelect
name="stock_status"
label="库存状态"
width="md"
valueEnum={PRODUCT_STOCK_STATUS_ENUM}
name="stock_status"
label="库存状态"
width="md"
valueEnum={PRODUCT_STOCK_STATUS_ENUM}
/>
</>
) : (
<></>
)}
<ProFormSelect
name="status"
label="产品状态"
width="md"
valueEnum={PRODUCT_STATUS_ENUM}
name="status"
label="产品状态"
width="md"
valueEnum={PRODUCT_STATUS_ENUM}
/>
<ProFormSelect
name="categories"
@ -432,12 +430,12 @@ export const UpdateForm: React.FC<{
label="产品图片"
initialValue={initialValues.images || [{}]}
creatorButtonProps={{
creatorButtonText: '添加图片',
creatorButtonText: '添加图片',
}}
>
<ProForm.Group>
<ProFormText name="src" label="图片URL" width="lg" />
<ProFormText name="alt" label="替代文本" width="md" />
<ProFormText name="src" label="图片URL" width="lg" />
<ProFormText name="alt" label="替代文本" width="md" />
</ProForm.Group>
</ProFormList>
<Divider />
@ -446,38 +444,38 @@ export const UpdateForm: React.FC<{
label="产品属性"
initialValue={initialValues.attributes || []}
creatorButtonProps={{
creatorButtonText: '添加属性',
creatorButtonText: '添加属性',
}}
>
<ProForm.Group>
<ProFormText name="name" label="属性名称" width="md" />
<ProFormSelect
name="options"
label="选项"
width="md"
mode="tags"
placeholder="输入选项并回车"
/>
<ProFormSelect
name="visible"
label="可见性"
width="xs"
options={[
{ label: '可见', value: true },
{ label: '隐藏', value: false },
]}
initialValue={true}
/>
<ProFormSelect
name="variation"
label="用于变体"
width="xs"
options={[
{ label: '是', value: true },
{ label: '否', value: false },
]}
initialValue={false}
/>
<ProFormText name="name" label="属性名称" width="md" />
<ProFormSelect
name="options"
label="选项"
width="md"
mode="tags"
placeholder="输入选项并回车"
/>
<ProFormSelect
name="visible"
label="可见性"
width="xs"
options={[
{ label: '可见', value: true },
{ label: '隐藏', value: false },
]}
initialValue={true}
/>
<ProFormSelect
name="variation"
label="用于变体"
width="xs"
options={[
{ label: '是', value: true },
{ label: '否', value: false },
]}
initialValue={false}
/>
</ProForm.Group>
</ProFormList>
</DrawerForm>
@ -489,34 +487,51 @@ export const UpdateVaritation: React.FC<{
values: any;
siteId?: string;
productId?: string | number;
}> = ({ tableRef, values: initialValues, siteId, productId: propProductId }) => {
}> = ({
tableRef,
values: initialValues,
siteId,
productId: propProductId,
}) => {
const { message } = App.useApp();
// 转换初始值,将字符串价格转换为数字以便编辑
const formValues = {
...initialValues,
regular_price: initialValues.regular_price ? parseFloat(initialValues.regular_price) : 0,
sale_price: initialValues.sale_price ? parseFloat(initialValues.sale_price) : 0,
stock_quantity: initialValues.stock_quantity ? parseInt(initialValues.stock_quantity) : 0,
regular_price: initialValues.regular_price
? parseFloat(initialValues.regular_price)
: 0,
sale_price: initialValues.sale_price
? parseFloat(initialValues.sale_price)
: 0,
stock_quantity: initialValues.stock_quantity
? parseInt(initialValues.stock_quantity)
: 0,
};
return (
<DrawerForm
title="编辑变体"
initialValues={formValues}
trigger={
<Button type="link" title="编辑变体" icon={<EditOutlined />}></Button>
<Button type="link" title="编辑变体" icon={<EditOutlined />}>
</Button>
}
autoFocusFirstInput
drawerProps={{
destroyOnHidden: true,
}}
onFinish={async (values) => {
const productId = propProductId || initialValues.externalProductId || initialValues.parent_id || initialValues.product_id;
const productId =
propProductId ||
initialValues.externalProductId ||
initialValues.parent_id ||
initialValues.product_id;
if (!siteId || !productId) {
message.error('缺少站点ID或产品ID');
return false;
message.error('缺少站点ID或产品ID');
return false;
}
try {
@ -525,14 +540,21 @@ export const UpdateVaritation: React.FC<{
...values,
regular_price: values.regular_price?.toString() || '',
sale_price: values.sale_price?.toString() || '',
price: values.sale_price?.toString() || values.regular_price?.toString() || '',
price:
values.sale_price?.toString() ||
values.regular_price?.toString() ||
'',
};
const variationId = initialValues.externalVariationId || initialValues.id;
await request(`/site-api/${siteId}/products/${productId}/variations/${variationId}`, {
method: 'PUT',
data: variationData
});
const variationId =
initialValues.externalVariationId || initialValues.id;
await request(
`/site-api/${siteId}/products/${productId}/variations/${variationId}`,
{
method: 'PUT',
data: variationData,
},
);
message.success('更新变体成功');
tableRef.current?.reload();
return true;
@ -575,16 +597,16 @@ export const UpdateVaritation: React.FC<{
fieldProps={{ precision: 0 }}
/>
<ProFormSelect
name="stock_status"
label="库存状态"
width="lg"
valueEnum={PRODUCT_STOCK_STATUS_ENUM}
name="stock_status"
label="库存状态"
width="lg"
valueEnum={PRODUCT_STOCK_STATUS_ENUM}
/>
<ProFormSelect
name="status"
label="产品状态"
width="lg"
valueEnum={PRODUCT_STATUS_ENUM}
name="status"
label="产品状态"
width="lg"
valueEnum={PRODUCT_STATUS_ENUM}
/>
</ProForm.Group>
</DrawerForm>
@ -603,7 +625,6 @@ export const UpdateVaritation: React.FC<{
// I'll remove BatchEdit from ProductsPage toolbar for now or implement batch update in Controller.
// I'll update BatchDelete.
export const BatchDeleteProducts: React.FC<{
tableRef: React.MutableRefObject<ActionType | undefined>;
selectedRowKeys: React.Key[];
@ -639,30 +660,56 @@ export const BatchDeleteProducts: React.FC<{
};
return (
<Button type="primary" danger title="批量删除" disabled={!hasSelection} onClick={handleBatchDelete} icon={<DeleteFilled />} />
<Button
type="primary"
danger
title="批量删除"
disabled={!hasSelection}
onClick={handleBatchDelete}
icon={<DeleteFilled />}
/>
);
};
export const BatchEditProducts: React.FC<{
tableRef: React.MutableRefObject<ActionType | undefined>;
selectedRowKeys: React.Key[];
setSelectedRowKeys: (keys: React.Key[]) => void;
selectedRows: any[];
siteId?: string;
}> = ({ tableRef, selectedRowKeys, setSelectedRowKeys, selectedRows, siteId }) => {
}> = ({
tableRef,
selectedRowKeys,
setSelectedRowKeys,
selectedRows,
siteId,
}) => {
const { message } = App.useApp();
return (
<ModalForm
title="批量编辑产品"
trigger={<Button disabled={!selectedRowKeys.length} type="primary" icon={<EditOutlined />}></Button>}
trigger={
<Button
disabled={!selectedRowKeys.length}
type="primary"
icon={<EditOutlined />}
>
</Button>
}
width={600}
modalProps={{ destroyOnHidden: true }}
onFinish={async (values) => {
if (!siteId) return false;
const updatePayload = selectedRows.map((row) => ({ id: row.id, ...values }));
const updatePayload = selectedRows.map((row) => ({
id: row.id,
...values,
}));
try {
const res = await request(`/site-api/${siteId}/products/batch`, { method: 'POST', data: { update: updatePayload } });
const res = await request(`/site-api/${siteId}/products/batch`, {
method: 'POST',
data: { update: updatePayload },
});
if (res.success) {
message.success('批量编辑成功');
tableRef.current?.reload();
@ -678,14 +725,26 @@ export const BatchEditProducts: React.FC<{
}}
>
<ProForm.Group>
<ProFormSelect name="status" label="产品状态" valueEnum={PRODUCT_STATUS_ENUM} />
<ProFormSelect name="stock_status" label="库存状态" valueEnum={PRODUCT_STOCK_STATUS_ENUM} />
<ProFormDigit name="stock_quantity" label="库存数量" fieldProps={{ precision: 0 }} />
<ProFormSelect
name="status"
label="产品状态"
valueEnum={PRODUCT_STATUS_ENUM}
/>
<ProFormSelect
name="stock_status"
label="库存状态"
valueEnum={PRODUCT_STOCK_STATUS_ENUM}
/>
<ProFormDigit
name="stock_quantity"
label="库存数量"
fieldProps={{ precision: 0 }}
/>
</ProForm.Group>
</ModalForm>
);
};
// Disable for now
// Disable for now
export const SetComponent: React.FC<any> = () => null; // Disable for now (relies on local productcontrollerProductbysku?)
export const ImportCsv: React.FC<{
@ -696,7 +755,11 @@ export const ImportCsv: React.FC<{
return (
<ModalForm
title="批量导入产品"
trigger={<Button type="primary" ghost icon={<PlusOutlined />}></Button>}
trigger={
<Button type="primary" ghost icon={<PlusOutlined />}>
</Button>
}
width={600}
modalProps={{ destroyOnHidden: true }}
onFinish={async (values) => {
@ -704,7 +767,10 @@ export const ImportCsv: React.FC<{
const csvText = values.csv || '';
const itemsList = values.items || [];
try {
const res = await request(`/site-api/${siteId}/products/import`, { method: 'POST', data: { csv: csvText, items: itemsList } });
const res = await request(`/site-api/${siteId}/products/import`, {
method: 'POST',
data: { csv: csvText, items: itemsList },
});
if (res.success) {
message.success('导入完成');
tableRef.current?.reload();
@ -718,16 +784,28 @@ export const ImportCsv: React.FC<{
}
}}
>
<ProFormTextArea name="csv" label="CSV文本" placeholder="粘贴CSV,首行为表头" />
<ProFormTextArea
name="csv"
label="CSV文本"
placeholder="粘贴CSV,首行为表头"
/>
<ProFormList name="items" label="或手动输入产品" initialValue={[]}>
<ProForm.Group>
<ProFormText name="name" label="名称" />
<ProFormText name="sku" label="SKU" />
<ProFormDigit name="regular_price" label="常规价" fieldProps={{ precision: 2 }} />
<ProFormDigit name="sale_price" label="促销价" fieldProps={{ precision: 2 }} />
<ProFormDigit
name="regular_price"
label="常规价"
fieldProps={{ precision: 2 }}
/>
<ProFormDigit
name="sale_price"
label="促销价"
fieldProps={{ precision: 2 }}
/>
</ProForm.Group>
</ProFormList>
</ModalForm>
);
};
// Disable for now
// Disable for now

View File

@ -78,9 +78,10 @@ export const classifyExtraTags = (
const fLower = flavorPart.toLowerCase();
const isFruit =
fruits.some((key) => fLower.includes(key.toLowerCase())) ||
tokens.some((t) => fruits.map(k => k.toLowerCase()).includes(t));
tokens.some((t) => fruits.map((k) => k.toLowerCase()).includes(t));
const isMint =
mints.some((key) => fLower.includes(key.toLowerCase())) || tokens.includes('mint');
mints.some((key) => fLower.includes(key.toLowerCase())) ||
tokens.includes('mint');
const extras: string[] = [];
if (isFruit) extras.push('Fruit');
@ -106,14 +107,18 @@ export const matchAttributes = (text: string, keys: string[]): string[] => {
/**
* @description Tags
*/
export const computeTags = (name: string, sku: string, config: TagConfig): string => {
export const computeTags = (
name: string,
sku: string,
config: TagConfig,
): string => {
const [brand, flavorPart, mg, dryness] = parseName(name, config.brands);
const tokens = splitFlavorTokens(flavorPart);
const flavorKeysLower = config.flavors.map(k => k.toLowerCase());
const flavorKeysLower = config.flavors.map((k) => k.toLowerCase());
const tokensForFlavor = tokens.filter(
(t) => flavorKeysLower.includes(t.toLowerCase()),
const tokensForFlavor = tokens.filter((t) =>
flavorKeysLower.includes(t.toLowerCase()),
);
const flavorTag = tokensForFlavor
@ -125,7 +130,9 @@ export const computeTags = (name: string, sku: string, config: TagConfig): strin
if (flavorTag) tags.push(flavorTag);
for (const t of tokensForFlavor) {
const isFruitKey = config.fruits.some(k => k.toLowerCase() === t.toLowerCase());
const isFruitKey = config.fruits.some(
(k) => k.toLowerCase() === t.toLowerCase(),
);
if (isFruitKey && t.toLowerCase() !== 'fruit') {
tags.push(t.charAt(0).toUpperCase() + t.slice(1));
}
@ -155,9 +162,7 @@ export const computeTags = (name: string, sku: string, config: TagConfig): strin
}
}
tags.push(
...classifyExtraTags(flavorPart, config.fruits, config.mints),
);
tags.push(...classifyExtraTags(flavorPart, config.fruits, config.mints));
const seen = new Set<string>();
const finalTags = tags.filter((t) => {

View File

@ -1,53 +1,50 @@
{
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",
"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",
"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: [
],
}
"default_address": [],
"addresses": []
}

View File

@ -128,11 +128,7 @@ const ListPage: React.FC = () => {
const stockRow = points.map(
(p) => stockMap.get(p.id || 0) || 0,
);
return [
item.productName || '',
item.sku || '',
...stockRow,
];
return [item.productName || '', item.sku || '', ...stockRow];
});
// 导出

View File

@ -564,15 +564,13 @@ const DetailForm: React.FC<{
const [form] = Form.useForm();
const initialValues = {
...values,
items: values?.items?.map(
(item: { productName: string; sku: string }) => ({
...item,
sku: {
label: item.productName,
value: item.sku,
},
}),
),
items: values?.items?.map((item: { productName: string; sku: string }) => ({
...item,
sku: {
label: item.productName,
value: item.sku,
},
})),
};
return (
<DrawerForm

View File

@ -223,7 +223,8 @@ const UpdateForm: React.FC<{
title="编辑"
initialValues={{
...initialValues,
areas: ((initialValues as any).areas?.map((area: any) => area.code) ?? []),
areas:
(initialValues as any).areas?.map((area: any) => area.code) ?? [],
}}
trigger={
<Button type="primary">

View File

@ -57,13 +57,13 @@ const TestModal: React.FC<{
const timer = setTimeout(async () => {
try {
const res = await templatecontrollerRendertemplate(
{ name: template.name || '' },
inputData
{ name: template.name || '' },
inputData,
);
if (res.success) {
setRenderedResult(res.data as unknown as string);
setRenderedResult(res.data as unknown as string);
} else {
setRenderedResult(`Error: ${res.message}`);
setRenderedResult(`Error: ${res.message}`);
}
} catch (error: any) {
setRenderedResult(`Error: ${error.message}`);
@ -88,9 +88,15 @@ const TestModal: React.FC<{
<Card bodyStyle={{ padding: 0, height: '300px', overflow: 'auto' }}>
<ReactJson
src={inputData}
onEdit={(edit) => setInputData(edit.updated_src as Record<string, any>)}
onAdd={(add) => setInputData(add.updated_src as Record<string, any>)}
onDelete={(del) => setInputData(del.updated_src as Record<string, any>)}
onEdit={(edit) =>
setInputData(edit.updated_src as Record<string, any>)
}
onAdd={(add) =>
setInputData(add.updated_src as Record<string, any>)
}
onDelete={(del) =>
setInputData(del.updated_src as Record<string, any>)
}
name={false}
displayDataTypes={false}
/>
@ -98,8 +104,17 @@ const TestModal: React.FC<{
</div>
<div style={{ flex: 1 }}>
<Typography.Title level={5}></Typography.Title>
<Card bodyStyle={{ padding: '16px', height: '300px', overflow: 'auto', backgroundColor: '#f5f5f5' }}>
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{renderedResult}</pre>
<Card
bodyStyle={{
padding: '16px',
height: '300px',
overflow: 'auto',
backgroundColor: '#f5f5f5',
}}
>
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>
{renderedResult}
</pre>
</Card>
</div>
</div>
@ -111,7 +126,9 @@ const List: React.FC = () => {
const actionRef = useRef<ActionType>();
const { message } = App.useApp();
const [testModalVisible, setTestModalVisible] = useState(false);
const [currentTemplate, setCurrentTemplate] = useState<API.Template | null>(null);
const [currentTemplate, setCurrentTemplate] = useState<API.Template | null>(
null,
);
const columns: ProColumns<API.Template>[] = [
{
@ -245,7 +262,6 @@ const CreateForm: React.FC<{
>
<ProFormText
name="name"
label="模板名称"
placeholder="请输入名称"
rules={[{ required: true, message: '请输入名称' }]}
@ -323,13 +339,13 @@ const UpdateForm: React.FC<{
}
}}
>
<ProFormText
name="name"
label="模板名称"
placeholder="请输入名称"
rules={[{ required: true, message: '请输入名称' }]}
/>
<ProForm.Item
<ProFormText
name="name"
label="模板名称"
placeholder="请输入名称"
rules={[{ required: true, message: '请输入名称' }]}
/>
<ProForm.Item
name="value"
label="值"
rules={[{ required: true, message: '请输入值' }]}
@ -345,24 +361,24 @@ const UpdateForm: React.FC<{
}}
/>
</ProForm.Item>
<ProFormTextArea
name="testData"
label="测试数据 (JSON)"
placeholder="请输入JSON格式的测试数据"
rules={[
{
validator: (_, value) => {
if (!value) return Promise.resolve();
try {
JSON.parse(value);
return Promise.resolve();
} catch (e) {
return Promise.reject(new Error('请输入有效的JSON格式'));
}
},
<ProFormTextArea
name="testData"
label="测试数据 (JSON)"
placeholder="请输入JSON格式的测试数据"
rules={[
{
validator: (_, value) => {
if (!value) return Promise.resolve();
try {
JSON.parse(value);
return Promise.resolve();
} catch (e) {
return Promise.reject(new Error('请输入有效的JSON格式'));
}
},
]}
/>
},
]}
/>
</DrawerForm>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -4,11 +4,10 @@ import {
ProForm,
ProFormSelect,
} from '@ant-design/pro-components';
import { request } from '@umijs/max';
import { Button, Card, Col, Input, message, Row, Upload } from 'antd';
import React, { useEffect, useState } from 'react';
import * as XLSX from 'xlsx';
import { request } from '@umijs/max';
import { attributes } from '../../../Product/Attribute/consts';
// 定义配置接口
interface TagConfig {
@ -95,9 +94,10 @@ const classifyExtraTags = (
const fLower = flavorPart.toLowerCase();
const isFruit =
fruitKeys.some((key) => fLower.includes(key.toLowerCase())) ||
tokens.some((t) => fruitKeys.map(k => k.toLowerCase()).includes(t));
tokens.some((t) => fruitKeys.map((k) => k.toLowerCase()).includes(t));
const isMint =
mintKeys.some((key) => fLower.includes(key.toLowerCase())) || tokens.includes('mint');
mintKeys.some((key) => fLower.includes(key.toLowerCase())) ||
tokens.includes('mint');
const extras: string[] = [];
if (isFruit) extras.push('Fruit');
@ -131,12 +131,12 @@ const computeTags = (name: string, sku: string, config: TagConfig): string => {
// 白名单模式:只保留在 flavorKeys 中的 token
// 且对比时忽略大小写
const flavorKeysLower = config.flavorKeys.map(k => k.toLowerCase());
const tokensForFlavor = tokens.filter(
(t) => flavorKeysLower.includes(t.toLowerCase()),
const flavorKeysLower = config.flavorKeys.map((k) => k.toLowerCase());
const tokensForFlavor = tokens.filter((t) =>
flavorKeysLower.includes(t.toLowerCase()),
);
// 将匹配到的 token 转为首字母大写
const flavorTag = tokensForFlavor
.map((t) => t.charAt(0).toUpperCase() + t.slice(1))
@ -149,12 +149,14 @@ const computeTags = (name: string, sku: string, config: TagConfig): string => {
// 添加额外的口味描述词
for (const t of tokensForFlavor) {
// 检查是否在 fruitKeys 中 (忽略大小写)
const isFruitKey = config.fruitKeys.some(k => k.toLowerCase() === t.toLowerCase());
const isFruitKey = config.fruitKeys.some(
(k) => k.toLowerCase() === t.toLowerCase(),
);
if (isFruitKey && t.toLowerCase() !== 'fruit') {
tags.push(t.charAt(0).toUpperCase() + t.slice(1));
tags.push(t.charAt(0).toUpperCase() + t.slice(1));
}
if (t.toLowerCase() === 'mint') {
tags.push('Mint');
tags.push('Mint');
}
}
@ -163,7 +165,7 @@ const computeTags = (name: string, sku: string, config: TagConfig): string => {
// 匹配 Humidity (Dry, Moist etc.)
tags.push(...matchAttributes(name, config.humidityKeys));
// 匹配 Category
tags.push(...matchAttributes(name, config.categoryKeys));
@ -174,7 +176,7 @@ const computeTags = (name: string, sku: string, config: TagConfig): string => {
if (/mix/i.test(name) || (sku && /mix/i.test(sku))) {
tags.push('Mix Pack');
}
// 保留原有的 MG 提取逻辑 (Regex is robust for "6MG", "6 MG")
if (mg) {
tags.push(`${mg} mg`);
@ -219,7 +221,8 @@ const WpToolPage: React.FC = () => {
const [csvData, setCsvData] = useState<any[]>([]); // 解析后的 CSV 数据
const [processedData, setProcessedData] = useState<any[]>([]); // 处理后待下载的数据
const [isProcessing, setIsProcessing] = useState(false); // 是否正在处理中
const [config, setConfig] = useState<TagConfig>({ // 动态配置
const [config, setConfig] = useState<TagConfig>({
// 动态配置
brands: [],
fruitKeys: [],
mintKeys: [],
@ -236,7 +239,7 @@ const WpToolPage: React.FC = () => {
try {
// 1. 获取所有字典列表以找到对应的 ID
const dictList = await request('/dict/list');
// 2. 根据字典名称获取字典项
const getItems = async (dictName: string) => {
const dict = dictList.find((d: any) => d.name === dictName);
@ -244,30 +247,41 @@ const WpToolPage: React.FC = () => {
console.warn(`Dictionary ${dictName} not found`);
return [];
}
const res = await request('/dict/items', { params: { dictId: dict.id } });
const res = await request('/dict/items', {
params: { dictId: dict.id },
});
return res.map((item: any) => item.name);
};
const [brands, fruitKeys, mintKeys, flavorKeys, strengthKeys, sizeKeys, humidityKeys, categoryKeys] = await Promise.all([
const [
brands,
fruitKeys,
mintKeys,
flavorKeys,
strengthKeys,
sizeKeys,
humidityKeys,
categoryKeys,
] = await Promise.all([
getItems('brand'),
getItems('fruit'), // 假设字典名为 fruit
getItems('mint'), // 假设字典名为 mint
getItems('mint'), // 假设字典名为 mint
getItems('flavor'), // 假设字典名为 flavor
getItems('strength'),
getItems('size'),
getItems('humidity'),
getItems('category'),
]);
const newConfig = {
brands,
fruitKeys,
mintKeys,
const newConfig = {
brands,
fruitKeys,
mintKeys,
flavorKeys,
strengthKeys,
sizeKeys,
humidityKeys,
categoryKeys
categoryKeys,
};
setConfig(newConfig);
form.setFieldsValue(newConfig);
@ -372,7 +386,16 @@ const WpToolPage: React.FC = () => {
// 获取表单中的最新配置
const config = await form.validateFields();
const { brands, fruitKeys, mintKeys, flavorKeys, strengthKeys, sizeKeys, humidityKeys, categoryKeys } = config;
const {
brands,
fruitKeys,
mintKeys,
flavorKeys,
strengthKeys,
sizeKeys,
humidityKeys,
categoryKeys,
} = config;
// 确保品牌按长度降序排序
const sortedBrands = [...brands].sort((a, b) => b.length - a.length);
@ -403,10 +426,9 @@ const WpToolPage: React.FC = () => {
content: 'Tags 生成成功!正在自动下载...',
key: 'processing',
});
// 自动下载
downloadData(dataWithTags);
} catch (error) {
message.error({
content: '处理失败,请检查配置或文件.',

View File

@ -11,7 +11,6 @@ import * as logistics from './logistics';
import * as media from './media';
import * as order from './order';
import * as product from './product';
import * as review from './review';
import * as site from './site';
import * as siteApi from './siteApi';
import * as statistics from './statistics';
@ -31,7 +30,6 @@ export default {
media,
order,
product,
review,
siteApi,
site,
statistics,

View File

@ -17,6 +17,20 @@ export async function productcontrollerCreateproduct(
});
}
/** 此处后端没有提供注释 GET /product/${param0} */
export async function productcontrollerGetproductbyid(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
params: API.productcontrollerGetproductbyidParams,
options?: { [key: string]: any },
) {
const { id: param0, ...queryParams } = params;
return request<API.ProductRes>(`/product/${param0}`, {
method: 'GET',
params: { ...queryParams },
...(options || {}),
});
}
/** 此处后端没有提供注释 PUT /product/${param0} */
export async function productcontrollerUpdateproduct(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
@ -564,6 +578,20 @@ export async function productcontrollerSearchproducts(
});
}
/** 此处后端没有提供注释 GET /product/site-sku/${param0} */
export async function productcontrollerGetproductbysitesku(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
params: API.productcontrollerGetproductbysiteskuParams,
options?: { [key: string]: any },
) {
const { siteSku: param0, ...queryParams } = params;
return request<API.ProductRes>(`/product/site-sku/${param0}`, {
method: 'GET',
params: { ...queryParams },
...(options || {}),
});
}
/** 此处后端没有提供注释 GET /product/size */
export async function productcontrollerCompatsize(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)

View File

@ -1,80 +0,0 @@
// @ts-ignore
/* eslint-disable */
import { request } from 'umi';
/** 此处后端没有提供注释 POST /review/create */
export async function reviewcontrollerCreate(
body: API.CreateReviewDTO,
options?: { [key: string]: any },
) {
return request<any>('/review/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: body,
...(options || {}),
});
}
/** 此处后端没有提供注释 DELETE /review/delete/${param0} */
export async function reviewcontrollerDelete(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
params: API.reviewcontrollerDeleteParams,
options?: { [key: string]: any },
) {
const { id: param0, ...queryParams } = params;
return request<any>(`/review/delete/${param0}`, {
method: 'DELETE',
params: { ...queryParams },
...(options || {}),
});
}
/** 此处后端没有提供注释 GET /review/get/${param0} */
export async function reviewcontrollerGet(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
params: API.reviewcontrollerGetParams,
options?: { [key: string]: any },
) {
const { id: param0, ...queryParams } = params;
return request<any>(`/review/get/${param0}`, {
method: 'GET',
params: { ...queryParams },
...(options || {}),
});
}
/** 此处后端没有提供注释 GET /review/list */
export async function reviewcontrollerList(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
params: API.reviewcontrollerListParams,
options?: { [key: string]: any },
) {
return request<any>('/review/list', {
method: 'GET',
params: {
...params,
},
...(options || {}),
});
}
/** 此处后端没有提供注释 PUT /review/update/${param0} */
export async function reviewcontrollerUpdate(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
params: API.reviewcontrollerUpdateParams,
body: API.UpdateReviewDTO,
options?: { [key: string]: any },
) {
const { id: param0, ...queryParams } = params;
return request<any>(`/review/update/${param0}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
params: { ...queryParams },
data: body,
...(options || {}),
});
}

View File

@ -163,6 +163,28 @@ export async function siteapicontrollerConvertmediatowebp(
);
}
/** 此处后端没有提供注释 POST /site-api/${param0}/media/create */
export async function siteapicontrollerCreatemedia(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
params: API.siteapicontrollerCreatemediaParams,
body: API.UploadMediaDTO,
options?: { [key: string]: any },
) {
const { siteId: param0, ...queryParams } = params;
return request<API.UnifiedMediaPaginationDTO>(
`/site-api/${param0}/media/create`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
params: { ...queryParams },
data: body,
...(options || {}),
},
);
}
/** 此处后端没有提供注释 GET /site-api/${param0}/media/export */
export async function siteapicontrollerExportmedia(
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)

View File

@ -185,22 +185,16 @@ declare namespace API {
};
type CreateReviewDTO = {
/** 站点ID */
siteId?: number;
/** 产品ID */
productId?: number;
/** 客户ID */
customerId?: number;
/** 评论者姓名 */
author?: string;
/** 评论者电子邮件 */
email?: string;
product_id?: Record<string, any>;
/** 评论内容 */
content?: string;
/** 评分 (1-5) */
review?: string;
/** 评论者 */
reviewer?: string;
/** 评论者邮箱 */
reviewer_email?: string;
/** 评分 */
rating?: number;
/** 状态 */
status?: 'pending' | 'approved' | 'rejected';
};
type CreateSiteDTO = {
@ -834,18 +828,18 @@ declare namespace API {
type Product = {
/** ID */
id: number;
/** sku */
sku?: string;
/** 类型 */
type?: string;
/** 产品名称 */
name: string;
/** 产品中文名称 */
nameCn?: string;
/** 产品描述 */
description?: string;
/** 产品简短描述 */
shortDescription?: string;
/** sku */
sku?: string;
/** 产品描述 */
description?: string;
/** 价格 */
price?: number;
/** 促销价格 */
@ -961,6 +955,14 @@ declare namespace API {
id: number;
};
type productcontrollerGetproductbyidParams = {
id: number;
};
type productcontrollerGetproductbysiteskuParams = {
siteSku: string;
};
type productcontrollerGetproductcomponentsParams = {
id: number;
};
@ -1047,7 +1049,7 @@ declare namespace API {
type ProductSiteSku = {
/** 站点 SKU */
code?: string;
siteSku?: string;
};
type ProductsRes = {
@ -1230,19 +1232,6 @@ declare namespace API {
stockPointId?: number;
};
type QueryReviewDTO = {
/** 当前页码 */
current?: number;
/** 每页数量 */
pageSize?: number;
/** 站点ID */
siteId?: number;
/** 产品ID */
productId?: number;
/** 状态 */
status?: string;
};
type QueryServiceDTO = {
/** 页码 */
current?: number;
@ -1348,31 +1337,6 @@ declare namespace API {
data?: RateDTO[];
};
type reviewcontrollerDeleteParams = {
id: number;
};
type reviewcontrollerGetParams = {
id: number;
};
type reviewcontrollerListParams = {
/** 当前页码 */
current?: number;
/** 每页数量 */
pageSize?: number;
/** 站点ID */
siteId?: number;
/** 产品ID */
productId?: number;
/** 状态 */
status?: string;
};
type reviewcontrollerUpdateParams = {
id: number;
};
type Service = {
id?: string;
carrier_name?: string;
@ -1486,6 +1450,10 @@ declare namespace API {
siteId: number;
};
type siteapicontrollerCreatemediaParams = {
siteId: number;
};
type siteapicontrollerCreateordernoteParams = {
id: string;
siteId: number;
@ -2537,9 +2505,11 @@ declare namespace API {
/** 更新时间 */
date_modified?: string;
/** 产品链接 */
frontendUrl?: string;
permalink?: string;
/** 原始数据(保留备用) */
raw?: Record<string, any>;
/** ERP产品信息 */
erpProduct?: Record<string, any>;
};
type UnifiedProductPaginationDTO = {
@ -2728,11 +2698,11 @@ declare namespace API {
type UpdateReviewDTO = {
/** 评论内容 */
content?: string;
/** 评分 (1-5) */
review?: string;
/** 评分 */
rating?: number;
/** 状态 */
status?: 'pending' | 'approved' | 'rejected';
status?: string;
};
type UpdateSiteDTO = {
@ -2740,6 +2710,8 @@ declare namespace API {
areas?: any;
/** 绑定仓库ID列表 */
stockPointIds?: any;
/** 站点网站URL */
websiteUrl?: string;
};
type UpdateStockDTO = {
@ -2805,6 +2777,13 @@ declare namespace API {
siteId?: number;
};
type UploadMediaDTO = {
/** Base64 编码的文件内容 */
file?: string;
/** 文件名 */
filename?: string;
};
type usercontrollerUpdateuserParams = {
id?: number;
};