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:
yeuimu
2026-08-26 14:23:09 +08:00
parent be0b90e68f
commit 6c61a4e871
982 changed files with 74156 additions and 179393 deletions
+138 -138
View File
@@ -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,
};
}
+165 -165
View File
@@ -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,
};
}
+387 -387
View File
@@ -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,
};
}