feat(product-center): implement Figma-designed UI, upload module, and website tests
- Redesign website homepage & product-center per Figma (fonts, logos, hero/footer/customer cases) - Add API upload module (multer) with static serving for uploads/public assets - Add OriginGood.delisted flag and SDS request retry logic - Add admin ImageUpload component and goods import/upload flows - Add vitest suite for website components and composables (32 tests) - Add skills, docs, plans and PRODUCT.md
This commit is contained in:
@@ -44,10 +44,10 @@ const COUNTRY_NAME_MAP: Record<string, string> = {
|
||||
'墨西哥工厂本地直发': '墨西哥',
|
||||
'巴西本地工厂直发': '巴西',
|
||||
'中东本地工厂直发': '中东',
|
||||
'欧洲波兰工厂直发': '欧洲波兰',
|
||||
'欧洲西班牙工厂直发': '欧洲西班牙',
|
||||
'欧洲德国工厂本地直发': '欧洲德国',
|
||||
'欧洲意大利工厂直发': '欧洲意大利',
|
||||
'欧洲波兰工厂直发': '波兰',
|
||||
'欧洲西班牙工厂直发': '西班牙',
|
||||
'欧洲德国工厂本地直发': '德国',
|
||||
'欧洲意大利工厂直发': '意大利',
|
||||
'英国本地直发': '英国',
|
||||
'加拿大本地工厂直发': '加拿大',
|
||||
'澳大利亚本地工厂直发': '澳大利亚',
|
||||
|
||||
@@ -56,6 +56,64 @@ export interface ProductQuery {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export function resolveBackendAssetUrl(value: string | null, backendUrl: string): string | null {
|
||||
if (!value || /^(?:https?:|data:|blob:)/i.test(value)) return value;
|
||||
return new URL(value, `${backendUrl.replace(/\/$/, '')}/`).toString();
|
||||
}
|
||||
|
||||
export function resolveCountryIdFromQuery(
|
||||
value: string | string[] | null | undefined,
|
||||
countries: Country[],
|
||||
): string | undefined {
|
||||
const countryId = Array.isArray(value) ? value[0] : value;
|
||||
return countryId && countries.some((country) => country.id === countryId) ? countryId : undefined;
|
||||
}
|
||||
|
||||
export function resolveCategoryIdFromQuery(
|
||||
value: string | string[] | null | undefined,
|
||||
categories: Category[],
|
||||
): string | undefined {
|
||||
const categoryId = Array.isArray(value) ? value[0] : value;
|
||||
const containsId = (nodes: Category[]): boolean => nodes.some(
|
||||
(category) => category.id === categoryId || containsId(category.children),
|
||||
);
|
||||
return categoryId && containsId(categories) ? categoryId : undefined;
|
||||
}
|
||||
|
||||
export function findCategoryIdByName(name: string, categories: Category[]): string | undefined {
|
||||
for (const category of categories) {
|
||||
if (category.name === name) return category.id;
|
||||
const childId = findCategoryIdByName(name, category.children);
|
||||
if (childId) return childId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findCategoryIdForPodName(name: string, categories: Category[]): string | undefined {
|
||||
const rootName = name.includes('男士')
|
||||
? '男士服装'
|
||||
: name.includes('女士')
|
||||
? '女士服装'
|
||||
: /童装|儿童/.test(name)
|
||||
? '儿童服装'
|
||||
: undefined;
|
||||
const root = rootName ? categories.find((category) => category.name === rootName) : undefined;
|
||||
if (!root) return findCategoryIdByName(name, categories);
|
||||
|
||||
const childName = name.includes('T恤')
|
||||
? 'T恤'
|
||||
: name.includes('裤')
|
||||
? '裤装'
|
||||
: name.includes('卫衣')
|
||||
? '卫衣'
|
||||
: name.includes('裙')
|
||||
? '裙装'
|
||||
: name.includes('内衣')
|
||||
? '内衣'
|
||||
: undefined;
|
||||
return root.children.find((category) => category.name === childName)?.id ?? root.id;
|
||||
}
|
||||
|
||||
interface BackendCategoryNode {
|
||||
id: string;
|
||||
categoryName: string;
|
||||
@@ -111,21 +169,21 @@ interface BackendEnvelope<T> {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
function mapCategory(node: BackendCategoryNode): Category {
|
||||
function mapCategory(node: BackendCategoryNode, backendUrl: string): Category {
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.categoryName,
|
||||
icon: node.categoryIcon,
|
||||
icon: resolveBackendAssetUrl(node.categoryIcon, backendUrl),
|
||||
parentId: node.parentCategoryId,
|
||||
children: node.children.map(mapCategory),
|
||||
children: node.children.map((child) => mapCategory(child, backendUrl)),
|
||||
};
|
||||
}
|
||||
|
||||
function mapCountry(c: BackendCountry): Country {
|
||||
function mapCountry(c: BackendCountry, backendUrl: string): Country {
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.countryName,
|
||||
icon: c.countryIcon,
|
||||
icon: resolveBackendAssetUrl(c.countryIcon, backendUrl),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,18 +218,41 @@ export function sortTagsByGroup(tags: Tag[]): Tag[] {
|
||||
});
|
||||
}
|
||||
|
||||
function mapProduct(p: BackendProduct): Product {
|
||||
export function selectExclusiveTagIds(selectedIds: string[], tags: Tag[], tagId: string): string[] {
|
||||
const selectedTag = tags.find((tag) => tag.id === tagId);
|
||||
if (!selectedTag) return selectedIds;
|
||||
if (selectedIds.includes(tagId)) return selectedIds.filter((id) => id !== tagId);
|
||||
|
||||
const selectedGroupId = selectedTag.group?.id;
|
||||
const otherGroups = selectedIds.filter((id) => {
|
||||
const tag = tags.find((item) => item.id === id);
|
||||
return !selectedGroupId || tag?.group?.id !== selectedGroupId;
|
||||
});
|
||||
return [...otherGroups, tagId];
|
||||
}
|
||||
|
||||
export function resolveDefaultProductFilters(
|
||||
_categories: Category[],
|
||||
_tags: Tag[],
|
||||
): Pick<ProductQuery, 'categoryId' | 'tagIds'> {
|
||||
return {
|
||||
categoryId: undefined,
|
||||
tagIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function mapProduct(p: BackendProduct, backendUrl: string): Product {
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.goodName,
|
||||
priority: p.goodPriority,
|
||||
image: p.image,
|
||||
image: resolveBackendAssetUrl(p.image, backendUrl),
|
||||
price: p.price,
|
||||
country: mapCountry(p.country),
|
||||
country: mapCountry(p.country, backendUrl),
|
||||
category: {
|
||||
id: p.category.id,
|
||||
name: p.category.categoryName,
|
||||
icon: p.category.categoryIcon,
|
||||
icon: resolveBackendAssetUrl(p.category.categoryIcon, backendUrl),
|
||||
parentId: null,
|
||||
children: [],
|
||||
},
|
||||
@@ -181,6 +262,7 @@ function mapProduct(p: BackendProduct): Product {
|
||||
}
|
||||
|
||||
export function useProductCenter() {
|
||||
const backendUrl = useRuntimeConfig().public.backendUrl;
|
||||
const categories = useState<Category[]>('pc-categories', () => []);
|
||||
const countries = useState<Country[]>('pc-countries', () => []);
|
||||
const tags = useState<Tag[]>('pc-tags', () => []);
|
||||
@@ -216,15 +298,15 @@ export function useProductCenter() {
|
||||
$fetch<BackendEnvelope<BackendCategoryNode[]>>('/api/backend/categories'),
|
||||
$fetch<BackendEnvelope<BackendCountry[]>>('/api/backend/countries'),
|
||||
]);
|
||||
categories.value = cats.data.map(mapCategory);
|
||||
countries.value = cos.data.map(mapCountry);
|
||||
categories.value = cats.data.map((category) => mapCategory(category, backendUrl));
|
||||
countries.value = cos.data.map((country) => mapCountry(country, backendUrl));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load filters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProducts(): Promise<void> {
|
||||
async function fetchProducts(options: { ignoreTags?: boolean } = {}): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: Record<string, string | number> = {
|
||||
@@ -233,14 +315,16 @@ export function useProductCenter() {
|
||||
};
|
||||
if (query.countryId) params.countryId = query.countryId;
|
||||
if (query.categoryId) params.categoryId = query.categoryId;
|
||||
if (query.tagIds.length > 0) params.tagIds = query.tagIds.join(',');
|
||||
if (!options.ignoreTags && query.tagIds.length > 0) {
|
||||
params.tagIds = query.tagIds.join(',');
|
||||
}
|
||||
if (query.keyword?.trim()) params.keyword = query.keyword.trim();
|
||||
|
||||
const response = await $fetch<BackendEnvelope<BackendPaginatedProducts>>(
|
||||
'/api/backend/goods',
|
||||
{ params },
|
||||
);
|
||||
products.value = response.data.items.map(mapProduct);
|
||||
products.value = response.data.items.map((product) => mapProduct(product, backendUrl));
|
||||
total.value = response.data.total;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch products:', error);
|
||||
@@ -262,12 +346,7 @@ export function useProductCenter() {
|
||||
}
|
||||
|
||||
function setTag(id: string): void {
|
||||
const idx = query.tagIds.indexOf(id);
|
||||
if (idx >= 0) {
|
||||
query.tagIds.splice(idx, 1);
|
||||
} else {
|
||||
query.tagIds.push(id);
|
||||
}
|
||||
query.tagIds = selectExclusiveTagIds(query.tagIds, tags.value, id);
|
||||
query.page = 1;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user