forked from yoone/WEB
parent
d2a84a9a4a
commit
2ee8964bce
|
|
@ -166,8 +166,8 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '产品品牌空间',
|
name: '产品品牌空间',
|
||||||
path: '/product/brandspace',
|
path: '/product/groupBy',
|
||||||
component: './Product/BrandSpace',
|
component: './Product/GroupBy',
|
||||||
},
|
},
|
||||||
// sync
|
// sync
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,368 @@
|
||||||
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { PageContainer, ProFromSelect } from '@ant-design/pro-components';
|
||||||
|
import { Card, Collapse, Divider, Image, Select, Space, Typography, message } from 'antd';
|
||||||
|
import { categorycontrollerGetall } from '@/servers/api/category';
|
||||||
|
import { productcontrollerGetproductlistgrouped } from '@/servers/api/product';
|
||||||
|
import { dictcontrollerGetdictitems } from '@/servers/api/dict';
|
||||||
|
|
||||||
|
// Define interfaces
|
||||||
|
interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
attributes: string[]; // List of attribute names for this category
|
||||||
|
}
|
||||||
|
interface Attribute {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttributeValue {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
titleCN?: string;
|
||||||
|
value?: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: number;
|
||||||
|
sku: string;
|
||||||
|
name: string;
|
||||||
|
image?: string;
|
||||||
|
brandId: number;
|
||||||
|
brandName: string;
|
||||||
|
attributes: { [key: string]: number }; // attribute name to attribute value id mapping
|
||||||
|
price?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grouped products by attribute value
|
||||||
|
interface GroupedProducts {
|
||||||
|
[attributeValueId: string]: Product[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProductCard component for displaying single product
|
||||||
|
const ProductCard: React.FC<{ product: Product }> = ({ product }) => {
|
||||||
|
return (
|
||||||
|
<Card hoverable style={{ width: 240 }}>
|
||||||
|
{/* <div style={{ height: 180, overflow: 'hidden', marginBottom: '12px' }}>
|
||||||
|
<Image
|
||||||
|
src={product.image || 'https://via.placeholder.com/240x180?text=No+Image'}
|
||||||
|
alt={product.name}
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: '4px' }}>
|
||||||
|
{product.sku}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text ellipsis style={{ width: '100%', display: 'block', marginBottom: '8px' }}>
|
||||||
|
{product.name}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text strong style={{ fontSize: 16, color: '#ff4d4f', display: 'block' }}>
|
||||||
|
¥{product.price || '--'}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ProductGroup component for displaying grouped products
|
||||||
|
const ProductGroup: React.FC<{
|
||||||
|
attributeValueId: string;
|
||||||
|
groupProducts: Product[];
|
||||||
|
attributeValue: AttributeValue | undefined;
|
||||||
|
attributeName: string;
|
||||||
|
}> = ({ attributeValueId, groupProducts, attributeValue }) => {
|
||||||
|
// State for collapse control
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
|
||||||
|
// Create collapse panel header
|
||||||
|
const panelHeader = (
|
||||||
|
<Space>
|
||||||
|
{attributeValue?.image && (
|
||||||
|
<Image
|
||||||
|
src={attributeValue.image}
|
||||||
|
style={{ width: 24, height: 24, objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
|
<span>
|
||||||
|
{attributeValue?.titleCN || attributeValue?.title || attributeValue?.name || attributeValueId||'未知'}
|
||||||
|
(共 {groupProducts.length} 个产品)
|
||||||
|
</span>
|
||||||
|
</Typography.Title>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapse
|
||||||
|
activeKey={isCollapsed ? [] : [attributeValueId]}
|
||||||
|
onChange={(key) => setIsCollapsed(Array.isArray(key) && key.length === 0)}
|
||||||
|
ghost
|
||||||
|
bordered={false}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: attributeValueId,
|
||||||
|
label: panelHeader,
|
||||||
|
children: (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '16px', paddingTop: '8px' }}>
|
||||||
|
{groupProducts.map((product) => (
|
||||||
|
<ProductCard key={product.id} product={product} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main ProductGroupBy component
|
||||||
|
const ProductGroupBy: React.FC = () => {
|
||||||
|
// State management
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
|
// Store selected values for each attribute
|
||||||
|
const [attributeFilters, setAttributeFilters] = useState<{ [key: string]: number | null }>({});
|
||||||
|
|
||||||
|
// Group by attribute
|
||||||
|
const [groupByAttribute, setGroupByAttribute] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Products
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [groupedProducts, setGroupedProducts] = useState<GroupedProducts>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Extract all unique attributes from categories
|
||||||
|
const categoryAttributes = useMemo(() => {
|
||||||
|
if (!selectedCategory) return [];
|
||||||
|
const categoryItem = categories.find((category: any) => category.name === selectedCategory);
|
||||||
|
if (!categoryItem) return [];
|
||||||
|
const attributesList: Attribute[] = categoryItem.attributes.map((attribute: any, index) => ({
|
||||||
|
...attribute.attributeDict,
|
||||||
|
id: index + 1,
|
||||||
|
}));
|
||||||
|
return attributesList;
|
||||||
|
}, [selectedCategory]);
|
||||||
|
|
||||||
|
// Fetch categories list
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const response = await categorycontrollerGetall();
|
||||||
|
const rawCategories = Array.isArray(response) ? response : response?.data || [];
|
||||||
|
setCategories(rawCategories);
|
||||||
|
|
||||||
|
// Set default category
|
||||||
|
if (rawCategories.length > 0) {
|
||||||
|
const defaultCategory = rawCategories.find((category: any) => category.name === 'nicotine-pouches');
|
||||||
|
setSelectedCategory(defaultCategory?.name || rawCategories[0].name);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch categories:', error);
|
||||||
|
message.error('获取分类列表失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update category attributes when selected category changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCategory) return;
|
||||||
|
|
||||||
|
const category = categories.find(cat => cat.name === selectedCategory);
|
||||||
|
if (!category) return;
|
||||||
|
|
||||||
|
// Get attributes for this category
|
||||||
|
const attributesForCategory = categoryAttributes.filter(attr =>
|
||||||
|
attr.name === 'brand' || category.attributes.includes(attr.name)
|
||||||
|
);
|
||||||
|
// Reset attribute filters when category changes
|
||||||
|
const newFilters: { [key: string]: number | null } = {};
|
||||||
|
attributesForCategory.forEach(attr => {
|
||||||
|
newFilters[attr.name] = null;
|
||||||
|
});
|
||||||
|
setAttributeFilters(newFilters);
|
||||||
|
|
||||||
|
// Set default group by attribute
|
||||||
|
if (attributesForCategory.length > 0) {
|
||||||
|
setGroupByAttribute(attributesForCategory[0].name);
|
||||||
|
}
|
||||||
|
}, [selectedCategory, categories, categoryAttributes]);
|
||||||
|
|
||||||
|
// Handle attribute filter change
|
||||||
|
const handleAttributeFilterChange = (attributeName: string, value: number | null) => {
|
||||||
|
setAttributeFilters(prev => ({ ...prev, [attributeName]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch products based on filters
|
||||||
|
const fetchProducts = async () => {
|
||||||
|
if (!selectedCategory || !groupByAttribute) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = {
|
||||||
|
category: selectedCategory,
|
||||||
|
groupBy: groupByAttribute
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const response = await productcontrollerGetproductlistgrouped(params);
|
||||||
|
const grouped = response?.data || {};
|
||||||
|
setGroupedProducts(grouped);
|
||||||
|
|
||||||
|
// Flatten grouped products to get all products
|
||||||
|
const allProducts = Object.values(grouped).flat() as Product[];
|
||||||
|
setProducts(allProducts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch grouped products:', error);
|
||||||
|
message.error('获取分组产品列表失败');
|
||||||
|
setProducts([]);
|
||||||
|
setGroupedProducts({});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial data fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch products when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProducts();
|
||||||
|
}, [selectedCategory, attributeFilters, groupByAttribute]);
|
||||||
|
|
||||||
|
// Destructure antd components
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer title="品牌空间">
|
||||||
|
<div style={{ padding: '16px', background: '#fff' }}>
|
||||||
|
{/* Filter Section */}
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<Title level={4} style={{ marginBottom: '16px' }}>筛选条件</Title>
|
||||||
|
<Space direction="vertical" size="large">
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div>
|
||||||
|
<Text strong>选择分类:</Text>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择分类"
|
||||||
|
style={{ width: 300, marginLeft: '8px' }}
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={setSelectedCategory}
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="children"
|
||||||
|
>
|
||||||
|
{categories.map(category => (
|
||||||
|
<Option key={category.id} value={category.name}>
|
||||||
|
{category.title}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attribute Filters */}
|
||||||
|
{categoryAttributes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Text strong>属性筛选:</Text>
|
||||||
|
<Space direction="vertical" style={{ marginTop: '8px', width: '100%' }}>
|
||||||
|
{categoryAttributes.map(attr => (
|
||||||
|
<div key={attr.id} style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Text style={{ width: '100px' }}>{attr.title}:</Text>
|
||||||
|
<ProFromSelect
|
||||||
|
placeholder={`请选择${attr.title}`}
|
||||||
|
style={{ width: 300 }}
|
||||||
|
value={attributeFilters[attr.name] || null}
|
||||||
|
onChange={value => handleAttributeFilterChange(attr.name, value)}
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="children"
|
||||||
|
request={async (params) => {
|
||||||
|
try {
|
||||||
|
console.log('params', params,attr);
|
||||||
|
const response = await dictcontrollerGetdictitems({ dictId: attr.name });
|
||||||
|
const rawValues = Array.isArray(response) ? response : response?.data?.items || [];
|
||||||
|
const filteredValues = rawValues.filter((value: any) =>
|
||||||
|
value.dictId === attr.name || value.dict?.id === attr.name || value.dict?.name === attr.name
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
options: filteredValues.map((value: any) => ({
|
||||||
|
label: `${value.name}${value.titleCN || value.title}`,
|
||||||
|
value: value.id
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch ${attr.title} values:`, error);
|
||||||
|
message.error(`获取${attr.title}属性值失败`);
|
||||||
|
return { options: [] };
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Group By Attribute */}
|
||||||
|
{categoryAttributes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Text strong>分组依据:</Text>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择分组属性"
|
||||||
|
style={{ width: 300, marginLeft: '8px' }}
|
||||||
|
value={groupByAttribute}
|
||||||
|
onChange={setGroupByAttribute}
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="children"
|
||||||
|
>
|
||||||
|
{categoryAttributes.map(attr => (
|
||||||
|
<Option key={attr.id} value={attr.name}>
|
||||||
|
{attr.title}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Products Section */}
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ marginBottom: '16px' }}>产品列表 ({products.length} 个产品)</Title>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '64px' }}>
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</div>
|
||||||
|
) : groupByAttribute && Object.keys(groupedProducts).length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||||
|
{Object.entries(groupedProducts).map(([attrValueId, groupProducts]) => {
|
||||||
|
return (
|
||||||
|
<ProductGroup
|
||||||
|
key={attrValueId}
|
||||||
|
attributeValueId={attrValueId}
|
||||||
|
groupProducts={groupProducts}
|
||||||
|
// attributeValue={}
|
||||||
|
attributeName={groupByAttribute!}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: 'center', padding: '64px', background: '#fafafa', borderRadius: 8 }}>
|
||||||
|
<Text type="secondary">暂无产品</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductGroupBy;
|
||||||
Loading…
Reference in New Issue