export interface Country { id: string; name: string; icon: string | null; } export interface Category { id: string; name: string; icon: string | null; parentId: string | null; children: Category[]; } export interface TagGroup { id: string; name: string; icon: string | null; color: string | null; sortOrder: number; } export interface Tag { id: string; name: string; color: string | null; fontColor: string | null; group: { id: string; name: string; sortOrder: number } | null; } export interface Product { id: string; name: string; priority: number; image: string | null; price: string | null; country: Country; category: Category; tag: Tag | null; tags: Tag[]; } export interface PaginatedProducts { items: Product[]; total: number; page: number; pageSize: number; } export interface ProductQuery { countryId?: string; categoryId?: string; tagIds: string[]; keyword?: string; page: number; 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; categoryIcon: string | null; parentCategoryId: string | null; children: BackendCategoryNode[]; } interface BackendCountry { id: string; countryName: string; countryIcon: string | null; } interface BackendTagGroup { id: string; groupName: string; groupIcon: string | null; groupColor: string | null; sortOrder: number; } interface BackendTag { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; group: { id: string; groupName: string; sortOrder: number } | null; } interface BackendProduct { id: string; goodName: string; goodPriority: number; country: { id: string; countryName: string; countryIcon: string | null }; category: { id: string; categoryName: string; categoryIcon: string | null }; tag: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; group: { id: string; groupName: string; sortOrder: number } | null } | null; tags: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null; group: { id: string; groupName: string; sortOrder: number } | null }[]; image: string | null; price: string | null; createdAt: string; } interface BackendPaginatedProducts { items: BackendProduct[]; total: number; page: number; pageSize: number; } interface BackendEnvelope { data: T; success: boolean; } function mapCategory(node: BackendCategoryNode, backendUrl: string): Category { return { id: node.id, name: node.categoryName, icon: resolveBackendAssetUrl(node.categoryIcon, backendUrl), parentId: node.parentCategoryId, children: node.children.map((child) => mapCategory(child, backendUrl)), }; } function mapCountry(c: BackendCountry, backendUrl: string): Country { return { id: c.id, name: c.countryName, icon: resolveBackendAssetUrl(c.countryIcon, backendUrl), }; } function mapTag(t: BackendTag): Tag { return { id: t.id, name: t.tagName, color: t.tagColor, fontColor: t.tagFontColor, group: t.group ? { id: t.group.id, name: t.group.groupName, sortOrder: t.group.sortOrder } : null, }; } function mapTagGroup(g: BackendTagGroup): TagGroup { return { id: g.id, name: g.groupName, icon: g.groupIcon, color: g.groupColor, sortOrder: g.sortOrder, }; } /** Sort tags by their group.order (admin-configured). Tags without a group go last. */ export function sortTagsByGroup(tags: Tag[]): Tag[] { return [...tags].sort((a, b) => { const ao = a.group?.sortOrder ?? Number.MAX_SAFE_INTEGER; const bo = b.group?.sortOrder ?? Number.MAX_SAFE_INTEGER; return ao - bo; }); } 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 { return { categoryId: undefined, tagIds: [], }; } function mapProduct(p: BackendProduct, backendUrl: string): Product { return { id: p.id, name: p.goodName, priority: p.goodPriority, image: resolveBackendAssetUrl(p.image, backendUrl), price: p.price, country: mapCountry(p.country, backendUrl), category: { id: p.category.id, name: p.category.categoryName, icon: resolveBackendAssetUrl(p.category.categoryIcon, backendUrl), parentId: null, children: [], }, tag: p.tag ? mapTag(p.tag) : null, tags: p.tags ? p.tags.map(mapTag) : [], }; } export function useProductCenter() { const backendUrl = useRuntimeConfig().public.backendUrl; const categories = useState('pc-categories', () => []); const countries = useState('pc-countries', () => []); const tags = useState('pc-tags', () => []); const tagGroups = useState('pc-tag-groups', () => []); const products = useState('pc-products', () => []); const total = useState('pc-total', () => 0); const loading = useState('pc-loading', () => false); const query = reactive({ countryId: undefined, categoryId: undefined, tagIds: [], keyword: undefined, page: 1, pageSize: 20, }); const selectedCategory = computed(() => query.categoryId ?? null); const selectedCountry = computed(() => query.countryId ?? null); const selectedTagIds = computed(() => query.tagIds); async function loadFilters(): Promise { try { const needCats = categories.value.length === 0; const [tgs, grs] = await Promise.all([ $fetch>('/api/backend/tags'), $fetch>('/api/backend/tag-groups'), ]); tags.value = tgs.data.map(mapTag); tagGroups.value = grs.data.map(mapTagGroup); if (needCats) { const [cats, cos] = await Promise.all([ $fetch>('/api/backend/categories'), $fetch>('/api/backend/countries'), ]); 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(options: { ignoreTags?: boolean } = {}): Promise { loading.value = true; try { const params: Record = { page: query.page, pageSize: query.pageSize, }; if (query.countryId) params.countryId = query.countryId; if (query.categoryId) params.categoryId = query.categoryId; 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>( '/api/backend/goods', { params }, ); products.value = response.data.items.map((product) => mapProduct(product, backendUrl)); total.value = response.data.total; } catch (error) { console.error('Failed to fetch products:', error); products.value = []; total.value = 0; } finally { loading.value = false; } } function setCategory(id: string | null): void { query.categoryId = id ?? undefined; query.page = 1; } function setCountry(id: string | null): void { query.countryId = id ?? undefined; query.page = 1; } function setTag(id: string): void { query.tagIds = selectExclusiveTagIds(query.tagIds, tags.value, id); query.page = 1; } function clearTags(): void { query.tagIds = []; query.page = 1; } function setKeyword(value: string): void { query.keyword = value; query.page = 1; } function setPage(page: number): void { query.page = Math.max(1, page); } function setPageSize(size: number): void { query.pageSize = size; query.page = 1; } return { categories, countries, tags, tagGroups, products, total, loading, query, selectedCategory, selectedCountry, selectedTagIds, loadFilters, fetchProducts, setCategory, setCountry, setTag, clearTags, setKeyword, setPage, setPageSize, }; }