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:
yeuimu
2026-08-20 14:32:03 +08:00
parent b5fc88f3fa
commit 79fabd85f7
107 changed files with 5364 additions and 2601 deletions
@@ -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;
}