feat(deploy): production deployment setup and fixes
- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma binaryTargets for openssl 3.0 - nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal cron, http->https redirect - prisma: add origin_goods.delisted migration, sync missing schema (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz - api: CORS wildcard reflection, helmet CORP cross-origin, price backfill in persistProductDetail, categoryIcon ancestor fallback, mediaByColor per-color gallery in public goods detail - admin: /admin base path (vite + router) - import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
@@ -1,138 +1,138 @@
|
||||
export interface RecommendItem {
|
||||
id: number;
|
||||
title: string;
|
||||
image: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface RecommendCategory {
|
||||
id: number;
|
||||
title: string;
|
||||
items: RecommendItem[];
|
||||
}
|
||||
|
||||
export interface SolutionLink {
|
||||
id: number;
|
||||
title: string;
|
||||
link: string;
|
||||
tag?: 'hot' | 'new';
|
||||
}
|
||||
|
||||
export interface SolutionColumn {
|
||||
id: number;
|
||||
title: string;
|
||||
links: SolutionLink[];
|
||||
}
|
||||
|
||||
export interface NavBanner {
|
||||
image: string;
|
||||
link: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const MOCK_RECOMMEND_CATEGORIES: RecommendCategory[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: '热卖推荐',
|
||||
items: [
|
||||
{ id: 101, title: 'T恤 热销爆款', image: '/水洗T恤.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 102, title: '卫衣 秋冬必备', image: '/鸭舌帽.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 103, title: '帆布袋 创意定制', image: '/帆布袋.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 104, title: '手机壳 个性潮流', image: '/设计工具.png', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '新品上架',
|
||||
items: [
|
||||
{ id: 201, title: '棒球帽 运动风潮', image: '/鸭舌帽.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 202, title: '马克杯 定制礼物', image: '/设计工具.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 203, title: '帆布画 艺术家居', image: '/帆布袋.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 204, title: '抱枕 舒适生活', image: '/水洗T恤.png', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '节日专区',
|
||||
items: [
|
||||
{ id: 301, title: '圣诞系列 定制好礼', image: '/帆布袋.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 302, title: '情人节 浪漫定制', image: '/水洗T恤.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 303, title: '万圣节 搞怪周边', image: '/鸭舌帽.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 304, title: '春节 新年好物', image: '/设计工具.png', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_SOLUTION_COLUMNS: SolutionColumn[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: '跨境电商',
|
||||
links: [
|
||||
{ id: 12, title: 'Temu 卖家方案', link: 'https://inkpod.vip/portal/search', tag: 'new' },
|
||||
{ id: 13, title: 'eBay 卖家方案', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 14, title: '速卖通方案', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '独立站',
|
||||
links: [
|
||||
{ id: 21, title: 'Shopify 方案', link: 'https://inkpod.vip/portal/search', tag: 'hot' },
|
||||
{ id: 22, title: 'WooCommerce 方案', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 23, title: '品牌出海方案', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '定制场景',
|
||||
links: [
|
||||
{ id: 31, title: '礼品定制', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 32, title: '宠物定制', link: 'https://inkpod.vip/portal/search', tag: 'new' },
|
||||
{ id: 33, title: '二次元周边', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 34, title: '婚庆定制', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 35, title: '团体定制', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '服务支持',
|
||||
links: [
|
||||
{ id: 41, title: '在线设计工具', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 42, title: '供应链服务', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 43, title: '一件代发', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 44, title: '售后保障', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_SOLUTION_BANNER: NavBanner = {
|
||||
image: '/主视觉.png',
|
||||
link: 'https://inkpod.vip/portal/search',
|
||||
title: 'InkReach 全球 POD 供应链',
|
||||
};
|
||||
|
||||
export function useNavData() {
|
||||
const recommendCategories = ref<RecommendCategory[]>(MOCK_RECOMMEND_CATEGORIES);
|
||||
const solutionColumns = ref<SolutionColumn[]>(MOCK_SOLUTION_COLUMNS);
|
||||
const solutionBanner = ref<NavBanner | null>(MOCK_SOLUTION_BANNER);
|
||||
const activeRecommendIdx = ref(0);
|
||||
|
||||
const activeRecommendCategory = computed(
|
||||
() => recommendCategories.value[activeRecommendIdx.value] ?? null,
|
||||
);
|
||||
|
||||
function setActiveRecommend(idx: number): void {
|
||||
if (idx >= 0 && idx < recommendCategories.value.length) {
|
||||
activeRecommendIdx.value = idx;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
recommendCategories,
|
||||
solutionColumns,
|
||||
solutionBanner,
|
||||
activeRecommendIdx,
|
||||
activeRecommendCategory,
|
||||
setActiveRecommend,
|
||||
};
|
||||
}
|
||||
export interface RecommendItem {
|
||||
id: number;
|
||||
title: string;
|
||||
image: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface RecommendCategory {
|
||||
id: number;
|
||||
title: string;
|
||||
items: RecommendItem[];
|
||||
}
|
||||
|
||||
export interface SolutionLink {
|
||||
id: number;
|
||||
title: string;
|
||||
link: string;
|
||||
tag?: 'hot' | 'new';
|
||||
}
|
||||
|
||||
export interface SolutionColumn {
|
||||
id: number;
|
||||
title: string;
|
||||
links: SolutionLink[];
|
||||
}
|
||||
|
||||
export interface NavBanner {
|
||||
image: string;
|
||||
link: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const MOCK_RECOMMEND_CATEGORIES: RecommendCategory[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: '热卖推荐',
|
||||
items: [
|
||||
{ id: 101, title: 'T恤 热销爆款', image: '/水洗T恤.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 102, title: '卫衣 秋冬必备', image: '/鸭舌帽.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 103, title: '帆布袋 创意定制', image: '/帆布袋.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 104, title: '手机壳 个性潮流', image: '/设计工具.png', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '新品上架',
|
||||
items: [
|
||||
{ id: 201, title: '棒球帽 运动风潮', image: '/鸭舌帽.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 202, title: '马克杯 定制礼物', image: '/设计工具.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 203, title: '帆布画 艺术家居', image: '/帆布袋.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 204, title: '抱枕 舒适生活', image: '/水洗T恤.png', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '节日专区',
|
||||
items: [
|
||||
{ id: 301, title: '圣诞系列 定制好礼', image: '/帆布袋.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 302, title: '情人节 浪漫定制', image: '/水洗T恤.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 303, title: '万圣节 搞怪周边', image: '/鸭舌帽.png', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 304, title: '春节 新年好物', image: '/设计工具.png', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_SOLUTION_COLUMNS: SolutionColumn[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: '跨境电商',
|
||||
links: [
|
||||
{ id: 12, title: 'Temu 卖家方案', link: 'https://inkpod.vip/portal/search', tag: 'new' },
|
||||
{ id: 13, title: 'eBay 卖家方案', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 14, title: '速卖通方案', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '独立站',
|
||||
links: [
|
||||
{ id: 21, title: 'Shopify 方案', link: 'https://inkpod.vip/portal/search', tag: 'hot' },
|
||||
{ id: 22, title: 'WooCommerce 方案', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 23, title: '品牌出海方案', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '定制场景',
|
||||
links: [
|
||||
{ id: 31, title: '礼品定制', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 32, title: '宠物定制', link: 'https://inkpod.vip/portal/search', tag: 'new' },
|
||||
{ id: 33, title: '二次元周边', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 34, title: '婚庆定制', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 35, title: '团体定制', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '服务支持',
|
||||
links: [
|
||||
{ id: 41, title: '在线设计工具', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 42, title: '供应链服务', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 43, title: '一件代发', link: 'https://inkpod.vip/portal/search' },
|
||||
{ id: 44, title: '售后保障', link: 'https://inkpod.vip/portal/search' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_SOLUTION_BANNER: NavBanner = {
|
||||
image: '/主视觉.png',
|
||||
link: 'https://inkpod.vip/portal/search',
|
||||
title: 'InkReach 全球 POD 供应链',
|
||||
};
|
||||
|
||||
export function useNavData() {
|
||||
const recommendCategories = ref<RecommendCategory[]>(MOCK_RECOMMEND_CATEGORIES);
|
||||
const solutionColumns = ref<SolutionColumn[]>(MOCK_SOLUTION_COLUMNS);
|
||||
const solutionBanner = ref<NavBanner | null>(MOCK_SOLUTION_BANNER);
|
||||
const activeRecommendIdx = ref(0);
|
||||
|
||||
const activeRecommendCategory = computed(
|
||||
() => recommendCategories.value[activeRecommendIdx.value] ?? null,
|
||||
);
|
||||
|
||||
function setActiveRecommend(idx: number): void {
|
||||
if (idx >= 0 && idx < recommendCategories.value.length) {
|
||||
activeRecommendIdx.value = idx;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
recommendCategories,
|
||||
solutionColumns,
|
||||
solutionBanner,
|
||||
activeRecommendIdx,
|
||||
activeRecommendCategory,
|
||||
setActiveRecommend,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,165 +1,165 @@
|
||||
interface CategoryChild {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
parentName: string;
|
||||
tag: string;
|
||||
productNum: number;
|
||||
children: CategoryChild[];
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
tag: string;
|
||||
productNum: number;
|
||||
children: CategoryChild[];
|
||||
}
|
||||
|
||||
interface ProductItem {
|
||||
id: number;
|
||||
name: string;
|
||||
psd_img_url: string;
|
||||
thumbImgUrl: string;
|
||||
currentPrice: number;
|
||||
color_str: string[];
|
||||
}
|
||||
|
||||
interface ProductPage {
|
||||
totalCount: number;
|
||||
items: ProductItem[];
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface CountryGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
products: CategoryChild[];
|
||||
}
|
||||
|
||||
const COUNTRY_NAME_MAP: Record<string, string> = {
|
||||
'美国工厂直发': '美国',
|
||||
'日本本地工厂直发': '日本',
|
||||
'墨西哥工厂本地直发': '墨西哥',
|
||||
'巴西本地工厂直发': '巴西',
|
||||
'中东本地工厂直发': '中东',
|
||||
'欧洲波兰工厂直发': '波兰',
|
||||
'欧洲西班牙工厂直发': '西班牙',
|
||||
'欧洲德国工厂本地直发': '德国',
|
||||
'欧洲意大利工厂直发': '意大利',
|
||||
'英国本地直发': '英国',
|
||||
'加拿大本地工厂直发': '加拿大',
|
||||
'澳大利亚本地工厂直发': '澳大利亚',
|
||||
'韩国本地直发': '韩国',
|
||||
};
|
||||
|
||||
export function usePodProducts() {
|
||||
const categories = ref<Category[]>([]);
|
||||
const countryGroups = ref<CountryGroup[]>([]);
|
||||
const activeCountryIdx = ref(0);
|
||||
const productImages = ref<Map<number, string>>(new Map());
|
||||
const loading = ref(true);
|
||||
const loadingProducts = ref(false);
|
||||
|
||||
const countryPage = ref(0);
|
||||
const visibleCountryCount = 6;
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const data = await $fetch<Category[]>('/api/pod/categories', {
|
||||
method: 'POST',
|
||||
});
|
||||
categories.value = data;
|
||||
countryGroups.value = data.map((cat) => ({
|
||||
id: cat.id,
|
||||
name: COUNTRY_NAME_MAP[cat.name] ?? cat.name,
|
||||
products: cat.children,
|
||||
}));
|
||||
if (countryGroups.value.length > 0) {
|
||||
await fetchProductsForCountry(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch categories:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProductsForCountry = async (idx: number) => {
|
||||
const group = countryGroups.value[idx];
|
||||
if (!group) return;
|
||||
|
||||
loadingProducts.value = true;
|
||||
try {
|
||||
const children = group.products.slice(0, 10);
|
||||
const promises = children.map((child) =>
|
||||
$fetch<ProductPage>(`/api/pod/products/${child.id}`).then((res) => ({
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
image: res.items?.[0]?.psd_img_url ?? res.items?.[0]?.thumbImgUrl ?? '',
|
||||
})).catch(() => ({
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
image: '',
|
||||
})),
|
||||
);
|
||||
const results = await Promise.all(promises);
|
||||
const newMap = new Map(productImages.value);
|
||||
for (const r of results) {
|
||||
newMap.set(r.id, r.image);
|
||||
}
|
||||
productImages.value = newMap;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch products:', error);
|
||||
} finally {
|
||||
loadingProducts.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectCountry = (idx: number) => {
|
||||
activeCountryIdx.value = idx;
|
||||
countryPage.value = Math.floor(idx / visibleCountryCount);
|
||||
fetchProductsForCountry(idx);
|
||||
};
|
||||
|
||||
const visibleCountries = computed(() => {
|
||||
const start = countryPage.value * visibleCountryCount;
|
||||
return countryGroups.value.slice(start, start + visibleCountryCount);
|
||||
});
|
||||
|
||||
const canPrevCountry = computed(() => countryPage.value > 0);
|
||||
const canNextCountry = computed(
|
||||
() =>
|
||||
(countryPage.value + 1) * visibleCountryCount <
|
||||
countryGroups.value.length,
|
||||
);
|
||||
|
||||
const currentProducts = computed(() => {
|
||||
const group = countryGroups.value[activeCountryIdx.value];
|
||||
if (!group) return [];
|
||||
return group.products.slice(0, 10).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
image: productImages.value.get(p.id) ?? '',
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
categories,
|
||||
countryGroups,
|
||||
activeCountryIdx,
|
||||
productImages,
|
||||
loading,
|
||||
loadingProducts,
|
||||
countryPage,
|
||||
visibleCountryCount,
|
||||
visibleCountries,
|
||||
canPrevCountry,
|
||||
canNextCountry,
|
||||
currentProducts,
|
||||
fetchCategories,
|
||||
selectCountry,
|
||||
};
|
||||
}
|
||||
interface CategoryChild {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
parentName: string;
|
||||
tag: string;
|
||||
productNum: number;
|
||||
children: CategoryChild[];
|
||||
}
|
||||
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
tag: string;
|
||||
productNum: number;
|
||||
children: CategoryChild[];
|
||||
}
|
||||
|
||||
interface ProductItem {
|
||||
id: number;
|
||||
name: string;
|
||||
psd_img_url: string;
|
||||
thumbImgUrl: string;
|
||||
currentPrice: number;
|
||||
color_str: string[];
|
||||
}
|
||||
|
||||
interface ProductPage {
|
||||
totalCount: number;
|
||||
items: ProductItem[];
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface CountryGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
products: CategoryChild[];
|
||||
}
|
||||
|
||||
const COUNTRY_NAME_MAP: Record<string, string> = {
|
||||
'美国工厂直发': '美国',
|
||||
'日本本地工厂直发': '日本',
|
||||
'墨西哥工厂本地直发': '墨西哥',
|
||||
'巴西本地工厂直发': '巴西',
|
||||
'中东本地工厂直发': '中东',
|
||||
'欧洲波兰工厂直发': '波兰',
|
||||
'欧洲西班牙工厂直发': '西班牙',
|
||||
'欧洲德国工厂本地直发': '德国',
|
||||
'欧洲意大利工厂直发': '意大利',
|
||||
'英国本地直发': '英国',
|
||||
'加拿大本地工厂直发': '加拿大',
|
||||
'澳大利亚本地工厂直发': '澳大利亚',
|
||||
'韩国本地直发': '韩国',
|
||||
};
|
||||
|
||||
export function usePodProducts() {
|
||||
const categories = ref<Category[]>([]);
|
||||
const countryGroups = ref<CountryGroup[]>([]);
|
||||
const activeCountryIdx = ref(0);
|
||||
const productImages = ref<Map<number, string>>(new Map());
|
||||
const loading = ref(true);
|
||||
const loadingProducts = ref(false);
|
||||
|
||||
const countryPage = ref(0);
|
||||
const visibleCountryCount = 6;
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const data = await $fetch<Category[]>('/api/pod/categories', {
|
||||
method: 'POST',
|
||||
});
|
||||
categories.value = data;
|
||||
countryGroups.value = data.map((cat) => ({
|
||||
id: cat.id,
|
||||
name: COUNTRY_NAME_MAP[cat.name] ?? cat.name,
|
||||
products: cat.children,
|
||||
}));
|
||||
if (countryGroups.value.length > 0) {
|
||||
await fetchProductsForCountry(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch categories:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProductsForCountry = async (idx: number) => {
|
||||
const group = countryGroups.value[idx];
|
||||
if (!group) return;
|
||||
|
||||
loadingProducts.value = true;
|
||||
try {
|
||||
const children = group.products.slice(0, 10);
|
||||
const promises = children.map((child) =>
|
||||
$fetch<ProductPage>(`/api/pod/products/${child.id}`).then((res) => ({
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
image: res.items?.[0]?.psd_img_url ?? res.items?.[0]?.thumbImgUrl ?? '',
|
||||
})).catch(() => ({
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
image: '',
|
||||
})),
|
||||
);
|
||||
const results = await Promise.all(promises);
|
||||
const newMap = new Map(productImages.value);
|
||||
for (const r of results) {
|
||||
newMap.set(r.id, r.image);
|
||||
}
|
||||
productImages.value = newMap;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch products:', error);
|
||||
} finally {
|
||||
loadingProducts.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectCountry = (idx: number) => {
|
||||
activeCountryIdx.value = idx;
|
||||
countryPage.value = Math.floor(idx / visibleCountryCount);
|
||||
fetchProductsForCountry(idx);
|
||||
};
|
||||
|
||||
const visibleCountries = computed(() => {
|
||||
const start = countryPage.value * visibleCountryCount;
|
||||
return countryGroups.value.slice(start, start + visibleCountryCount);
|
||||
});
|
||||
|
||||
const canPrevCountry = computed(() => countryPage.value > 0);
|
||||
const canNextCountry = computed(
|
||||
() =>
|
||||
(countryPage.value + 1) * visibleCountryCount <
|
||||
countryGroups.value.length,
|
||||
);
|
||||
|
||||
const currentProducts = computed(() => {
|
||||
const group = countryGroups.value[activeCountryIdx.value];
|
||||
if (!group) return [];
|
||||
return group.products.slice(0, 10).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
image: productImages.value.get(p.id) ?? '',
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
categories,
|
||||
countryGroups,
|
||||
activeCountryIdx,
|
||||
productImages,
|
||||
loading,
|
||||
loadingProducts,
|
||||
countryPage,
|
||||
visibleCountryCount,
|
||||
visibleCountries,
|
||||
canPrevCountry,
|
||||
canNextCountry,
|
||||
currentProducts,
|
||||
fetchCategories,
|
||||
selectCountry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,319 +1,319 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
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) {
|
||||
const grouped = new Map<string, string[]>();
|
||||
@@ -326,77 +326,77 @@ export function useProductCenter() {
|
||||
[...grouped].map(([tagGroupId, tagIds]) => ({ tagGroupId, tagIds })),
|
||||
);
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user