395 lines
11 KiB
TypeScript
395 lines
11 KiB
TypeScript
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 {
|
|
goodId: 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<T> {
|
|
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<ProductQuery, 'categoryId' | 'tagIds'> {
|
|
return {
|
|
categoryId: undefined,
|
|
tagIds: [],
|
|
};
|
|
}
|
|
|
|
function mapProduct(p: BackendProduct, backendUrl: string): Product {
|
|
return {
|
|
id: p.goodId,
|
|
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<Category[]>('pc-categories', () => []);
|
|
const countries = useState<Country[]>('pc-countries', () => []);
|
|
const tags = useState<Tag[]>('pc-tags', () => []);
|
|
const tagGroups = useState<TagGroup[]>('pc-tag-groups', () => []);
|
|
const products = useState<Product[]>('pc-products', () => []);
|
|
const total = useState<number>('pc-total', () => 0);
|
|
const loading = useState<boolean>('pc-loading', () => false);
|
|
|
|
const query = reactive<ProductQuery>({
|
|
countryId: undefined,
|
|
categoryId: undefined,
|
|
tagIds: [],
|
|
keyword: undefined,
|
|
page: 1,
|
|
pageSize: 20,
|
|
});
|
|
|
|
const selectedCategory = computed<string | null>(() => query.categoryId ?? null);
|
|
const selectedCountry = computed<string | null>(() => query.countryId ?? null);
|
|
const selectedTagIds = computed<string[]>(() => query.tagIds);
|
|
|
|
async function loadFilters(): Promise<void> {
|
|
try {
|
|
const needCats = categories.value.length === 0;
|
|
const [tgs, grs] = await Promise.all([
|
|
$fetch<BackendEnvelope<BackendTag[]>>('/api/backend/tags'),
|
|
$fetch<BackendEnvelope<BackendTagGroup[]>>('/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<BackendEnvelope<BackendCategoryNode[]>>('/api/backend/categories'),
|
|
$fetch<BackendEnvelope<BackendCountry[]>>('/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<void> {
|
|
loading.value = true;
|
|
try {
|
|
const params: Record<string, string | number> = {
|
|
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<BackendEnvelope<BackendPaginatedProducts>>(
|
|
'/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,
|
|
};
|
|
}
|