fix: convert apps/website from submodule to regular directory

This commit is contained in:
yeuimu
2026-07-16 01:42:45 +08:00
parent f12ae12ad3
commit 9595030625
341 changed files with 50808 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
+24
View File
@@ -0,0 +1,24 @@
@import "tailwindcss";
@theme {
--color-inkreach-homepage-1: #f3f4f5;
--color-inkreach-homepage-2: #f8f9fb;
--color-inkreach-homepage-3: #fff6f0;
--color-inkreach-homepage-4: #FF6800;
--color-primary: #FF6800;
--color-inkreach-homepage-5: #0C0501;
--animate-marquee: marquee-scroll 30s linear infinite;
--animate-pulse-animation: pulse-glow 2s ease-in-out infinite;
@keyframes marquee-scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 104, 0, 0.4); }
50% { box-shadow: 0 0 0 12px rgba(255, 104, 0, 0); }
}
}
+54
View File
@@ -0,0 +1,54 @@
<template>
<footer class="product-footer text-white py-10 lg:py-16 px-4 lg:px-6 bg-inkreach-homepage-5">
<div class="max-w-7xl mx-auto">
<div class="space-y-6 md:grid md:grid-cols-2 lg:grid-cols-4 lg:gap-8">
<div class="md:col-span-2 lg:col-span-1">
<div class="text-2xl font-bold mb-4">Inkreach</div>
<p class="text-gray-400 text-sm">全球POD定制平台, 服务中国卖家出海</p>
</div>
<div class="grid grid-cols-3 gap-4 md:col-span-2 lg:col-span-3">
<div>
<h3 class="text-lg font-bold mb-4">产品</h3>
<ul class="space-y-2 text-gray-400">
<li><a href="#" class="hover:text-white transition-colors">定制卫衣</a></li>
<li><a href="#" class="hover:text-white transition-colors">定制T恤</a></li>
<li><a href="#" class="hover:text-white transition-colors">定制包包</a></li>
</ul>
</div>
<div>
<h3 class="text-lg font-bold mb-4">支持</h3>
<ul class="space-y-2 text-gray-400">
<li><a href="#" class="hover:text-white transition-colors">物流说明</a></li>
<li><a href="#" class="hover:text-white transition-colors">客服帮助</a></li>
<li><a href="#" class="hover:text-white transition-colors">售后客服</a></li>
</ul>
</div>
<div>
<h3 class="text-lg font-bold mb-4">联系</h3>
<ul class="space-y-2 text-gray-400">
<li><a href="#" class="hover:text-white transition-colors cursor-pointer">联系我们</a></li>
<li><a href="#" class="hover:text-white transition-colors cursor-pointer">官方公众号</a></li>
</ul>
</div>
</div>
</div>
<div class="border-t border-gray-800 mt-8 lg:mt-12 pt-6 lg:pt-8 text-center text-gray-500 text-sm">
Copyright @Inkreach 印美达POD全球供应链 All Rights Reserved.
<a
href="https://beian.miit.gov.cn/#/Integrated/index"
target="_blank"
style="color: border-gray-800; margin: 0px 10px;"
>粤ICP备xxx号</a>
广州市印美达供应链有限公司版权所有
</div>
</div>
</footer>
</template>
<style scoped>
@media (min-width: 901px) {
.product-footer {
margin-left: 240px;
}
}
</style>
+339
View File
@@ -0,0 +1,339 @@
<script setup lang="ts">
import type { RecommendCategory, SolutionColumn, NavBanner } from '~/composables/useNavData';
const PORTAL_URL = 'https://inkpod.vip/portal/search';
const LOGIN_URL = 'https://inkpod.vip/user/login';
const mobileMenuOpen = ref(false);
const recommendMenuOpen = ref(false);
const solutionMenuOpen = ref(false);
const mobileRecommendOpen = ref(false);
const mobileSolutionOpen = ref(false);
const mobileRecommendTab = ref(0);
const {
recommendCategories,
solutionColumns,
solutionBanner,
activeRecommendIdx,
setActiveRecommend,
} = useNavData();
let recommendTimeout: ReturnType<typeof setTimeout> | null = null;
let solutionTimeout: ReturnType<typeof setTimeout> | null = null;
function openRecommendMenu(): void {
if (recommendTimeout) {
clearTimeout(recommendTimeout);
recommendTimeout = null;
}
recommendMenuOpen.value = true;
}
function closeRecommendMenu(): void {
recommendTimeout = setTimeout(() => {
recommendMenuOpen.value = false;
}, 150);
}
function openSolutionMenu(): void {
if (solutionTimeout) {
clearTimeout(solutionTimeout);
solutionTimeout = null;
}
solutionMenuOpen.value = true;
}
function closeSolutionMenu(): void {
solutionTimeout = setTimeout(() => {
solutionMenuOpen.value = false;
}, 150);
}
function toggleMobileMenu(): void {
mobileMenuOpen.value = !mobileMenuOpen.value;
}
function closeMobileMenu(): void {
mobileMenuOpen.value = false;
mobileRecommendOpen.value = false;
mobileSolutionOpen.value = false;
}
function toggleMobileRecommend(): void {
mobileRecommendOpen.value = !mobileRecommendOpen.value;
mobileSolutionOpen.value = false;
}
function toggleMobileSolution(): void {
mobileSolutionOpen.value = !mobileSolutionOpen.value;
mobileRecommendOpen.value = false;
}
const mobileActiveCategory = computed(
() => recommendCategories.value[mobileRecommendTab.value] ?? null,
);
const route = useRoute();
const isProductCenter = computed(() => route.path === '/product-center');
</script>
<template>
<header class="pl-6 md:pl-10 lg:pl-16 pr-6 md:pr-10 lg:pr-20 flex items-center h-20 fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-100">
<div>
<img src="/logo-橙@2x.png" alt="Inkreach" class="h-12 lg:h-14" />
</div>
<nav class="hidden lg:flex gap-6 text-xl flex-1 ml-8 items-center h-20">
<NuxtLink to="/product-center"
class="transition-colors"
:class="isProductCenter
? 'text-black font-medium'
: 'text-gray-400 hover:text-primary hover:underline underline-offset-14 decoration-primary decoration-2'"
>产品中心</NuxtLink>
<a :href="PORTAL_URL" target="_blank"
class="transition-colors"
:class="isProductCenter
? 'text-gray-400'
: 'hover:text-primary hover:underline underline-offset-14 decoration-primary decoration-2'"
>在线设计</a>
<div
class="relative h-full flex items-center"
@mouseenter="openRecommendMenu"
@mouseleave="closeRecommendMenu"
>
<button
class="transition-colors flex items-center gap-1"
:class="[
recommendMenuOpen ? 'text-primary' : '',
isProductCenter && !recommendMenuOpen ? 'text-gray-400' : '',
]"
>
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">选品推荐</span>
<i class="fa-solid fa-chevron-down text-xs transition-transform duration-200" :class="recommendMenuOpen ? 'rotate-180' : ''"></i>
</button>
<NavMegaMenu
:categories="recommendCategories"
:active-index="activeRecommendIdx"
:visible="recommendMenuOpen"
@update:active-index="setActiveRecommend"
/>
</div>
<div
class="relative h-full flex items-center"
@mouseenter="openSolutionMenu"
@mouseleave="closeSolutionMenu"
>
<button
class="transition-colors flex items-center gap-1"
:class="[
solutionMenuOpen ? 'text-primary' : '',
isProductCenter && !solutionMenuOpen ? 'text-gray-400' : '',
]"
>
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">解决方案</span>
<i class="fa-solid fa-chevron-down text-xs transition-transform duration-200" :class="solutionMenuOpen ? 'rotate-180' : ''"></i>
</button>
<NavColumnMenu
:columns="solutionColumns"
:banner="solutionBanner"
:visible="solutionMenuOpen"
/>
</div>
</nav>
<div class="hidden lg:flex gap-6 text-xl items-center">
<a :href="LOGIN_URL" target="_blank"
class="hover:text-primary hover:underline underline-offset-12 decoration-primary decoration-2 transition-colors"
>登录</a>
<a :href="LOGIN_URL" target="_blank"
class="text-white rounded-lg px-6 py-1.5 self-center hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 pulse-animation bg-primary"
>立即开始</a>
</div>
<button
class="lg:hidden ml-auto p-2"
@click="toggleMobileMenu"
aria-label="Toggle menu"
>
<i v-if="!mobileMenuOpen" class="fa-solid fa-bars text-2xl"></i>
<i v-else class="fa-solid fa-xmark text-2xl"></i>
</button>
<Teleport to="body">
<Transition name="fade">
<div
v-if="mobileMenuOpen"
class="fixed inset-0 bg-black/50 z-40 lg:hidden"
@click="closeMobileMenu"
></div>
</Transition>
</Teleport>
<Transition name="slide">
<div
v-if="mobileMenuOpen"
class="fixed top-0 right-0 h-full w-80 bg-white shadow-2xl z-50 lg:hidden flex flex-col overflow-y-auto"
>
<div class="flex justify-between items-center p-6 pb-4">
<img src="/logo-橙.png" alt="Inkreach" class="h-8" />
<button @click="closeMobileMenu" class="p-2" aria-label="Close menu">
<i class="fa-solid fa-xmark text-2xl"></i>
</button>
</div>
<nav class="flex flex-col px-6 gap-1 text-lg">
<NuxtLink
to="/product-center"
class="py-3 hover:text-primary transition-colors"
@click="closeMobileMenu"
>产品中心</NuxtLink>
<a :href="PORTAL_URL" target="_blank"
class="py-3 hover:text-primary transition-colors"
>在线设计</a>
<div>
<button
class="w-full py-3 flex items-center justify-between hover:text-primary transition-colors"
@click="toggleMobileRecommend"
>
<span>选品推荐</span>
<i
class="fa-solid fa-chevron-down text-xs transition-transform duration-200"
:class="mobileRecommendOpen ? 'rotate-180' : ''"
></i>
</button>
<Transition name="accordion">
<div v-if="mobileRecommendOpen" class="pl-3 pb-2">
<div class="flex gap-2 mb-3 flex-wrap">
<button
v-for="(cat, idx) in recommendCategories"
:key="cat.id"
class="px-3 py-1.5 rounded-full text-sm transition-colors"
:class="mobileRecommendTab === idx
? 'bg-primary text-white'
: 'bg-inkreach-homepage-1 text-gray-600'"
@click="mobileRecommendTab = idx"
>
{{ cat.title }}
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<a
v-for="item in mobileActiveCategory?.items ?? []"
:key="item.id"
:href="item.link"
target="_blank"
class="group block rounded-lg overflow-hidden"
>
<div class="w-full h-[70px] rounded-lg overflow-hidden bg-inkreach-homepage-1">
<img
:src="item.image"
:alt="item.title"
class="w-full h-full object-cover"
/>
</div>
<p class="mt-1 text-[13px] text-gray-700 group-hover:text-primary truncate">
{{ item.title }}
</p>
</a>
</div>
</div>
</Transition>
</div>
<div>
<button
class="w-full py-3 flex items-center justify-between hover:text-primary transition-colors"
@click="toggleMobileSolution"
>
<span>解决方案</span>
<i
class="fa-solid fa-chevron-down text-xs transition-transform duration-200"
:class="mobileSolutionOpen ? 'rotate-180' : ''"
></i>
</button>
<Transition name="accordion">
<div v-if="mobileSolutionOpen" class="pl-3 pb-2">
<div
v-for="col in solutionColumns"
:key="col.id"
class="mb-3"
>
<h4 class="text-[15px] font-medium text-gray-800 mb-2">
{{ col.title }}
</h4>
<ul class="space-y-1.5">
<li v-for="link in col.links" :key="link.id">
<a
:href="link.link"
target="_blank"
class="text-[14px] text-gray-600 hover:text-primary transition-colors flex items-center gap-1"
>
{{ link.title }}
<span
v-if="link.tag === 'hot'"
class="text-[10px] text-white bg-red-500 px-1 py-0.5 rounded leading-none"
>HOT</span>
<span
v-if="link.tag === 'new'"
class="text-[10px] text-white bg-green-500 px-1 py-0.5 rounded leading-none"
>NEW</span>
</a>
</li>
</ul>
</div>
</div>
</Transition>
</div>
</nav>
<div class="flex flex-col gap-4 px-6 mt-auto pb-6 pt-4">
<a :href="LOGIN_URL" target="_blank"
class="py-2.5 text-lg hover:text-primary transition-colors text-center"
>登录</a>
<a :href="LOGIN_URL" target="_blank"
class="text-white rounded-lg px-6 py-3 text-center hover:shadow-lg transition-all duration-300 pulse-animation bg-primary"
>立即开始</a>
</div>
</div>
</Transition>
</header>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
}
.accordion-enter-active,
.accordion-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.accordion-enter-from,
.accordion-leave-to {
opacity: 0;
max-height: 0;
}
.accordion-enter-to,
.accordion-leave-from {
max-height: 600px;
}
</style>
@@ -0,0 +1,50 @@
<template>
<section class="py-10 lg:py-16 px-4 lg:px-6 bg-cover bg-center bg-no-repeat" style="background-image: url('/公司简介背景.png')">
<div class="max-w-7xl mx-auto">
<h2 class="text-[clamp(1.5rem,3vw,2.5rem)] font-bold text-gray-900 text-center mb-8 lg:mb-12">公司简介</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8 mb-8 lg:mb-12">
<div>
<h3
class="text-xl lg:text-2xl font-bold mb-4"
style="background: linear-gradient(90deg, #000000, #ec6000); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent;"
>
用科技重新定义服装供应链 <br /><span>让每一个创意都能高效抵达全球</span>
</h3>
<p class="text-gray-600">Inkreach印美达全球, 一家以AI与智能印花技术驱动的全球化POD柔性供应链公司, 致力于成为全球创业者最可靠的成长伙伴</p>
</div>
<div>
<h3 class="text-lg lg:text-xl font-bold text-gray-900 mb-4">服务对象与场景</h3>
<p class="text-gray-600">服务于数千家跨境独立站TikTokTEMUSHEIN等平台商家, 提供从海外生产至末端物流的一站式供应链服务, 让电商创业者专注增长, 无惧后端履约</p>
</div>
<div>
<h3 class="text-lg lg:text-xl font-bold text-gray-900 mb-4">愿景</h3>
<p class="text-gray-600">我们坚信, 每一个创意都值得被完美呈现INKREACH将持续深耕智能供应链生态, 用科技消除制造壁垒, 让全球创业者专注发挥创造力, 共同书写个性化电商的新纪元</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
<div
v-for="cap in capabilities"
:key="cap.title"
class="bg-white border border-gray-100 rounded-xl p-5 lg:p-6"
>
<div class="flex items-center gap-3 mb-4">
<div class="w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
<i :class="cap.icon" class="text-primary"></i>
</div>
<h3 class="text-base lg:text-lg font-bold text-gray-900">{{ cap.title }}</h3>
</div>
<p class="text-gray-600 text-sm lg:text-base">{{ cap.desc }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup>
const capabilities = [
{ title: '前沿印花技术', desc: '搭载最新智能印花工艺,品质与效率兼得', icon: 'fa-solid fa-print' },
{ title: '深度AI应用', desc: '从设计至生产, 全链路智能化提效', icon: 'fa-solid fa-brain' },
{ title: '全球智能工厂', desc: '多国自营本土化POD工厂, 日均产能超20万单', icon: 'fa-solid fa-industry' },
{ title: '一站式柔性交付', desc: '覆盖生产至物流, 助力品牌从概念到商业落地', icon: 'fa-solid fa-handshake' },
];
</script>
+14
View File
@@ -0,0 +1,14 @@
<template>
<section class="py-10 lg:py-16 px-4 lg:px-6 bg-primary">
<div class="max-w-4xl mx-auto text-center">
<h2 class="text-[clamp(1.5rem,4vw,3rem)] font-bold text-white mb-3 lg:mb-4">开始你的POD生意, 现在就行动</h2>
<p class="text-white text-base lg:text-xl mb-6 lg:mb-8">加入成千上万的成功卖家, 开始你的定制商品生意</p>
<button
class="bg-white text-primary px-8 lg:px-10 py-3 lg:py-4 rounded-lg text-base lg:text-lg font-bold hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 pulse-animation"
>
立即免费使用
<i class="fa-solid fa-arrow-right ml-2"></i>
</button>
</div>
</section>
</template>
@@ -0,0 +1,84 @@
<script setup>
const customerCases = ref([
{ id: 1, platform: 'TEMU卖家', isPrimary: true, title: '复古水洗 经典重塑', description: '巡演灵感做旧款,粉丝自发应援首选', salesGrowth: '260%', monthlySales: '45W美元', image: 'https://picsum.photos/seed/case1/400/300' },
{ id: 2, platform: 'TikTok卖家', isPrimary: false, title: '冠军荣耀 同款套装', description: '夺冠纪念球衣,球迷珍藏爆款', salesGrowth: '300%', monthlySales: '68W美元', image: 'https://picsum.photos/seed/case2/400/300' },
{ id: 3, platform: 'SHEIN卖家', isPrimary: false, title: '潮流印花 全棉舒适', description: '自制设计印花图案,无尾货压力轻松售', salesGrowth: '180%', monthlySales: '22W美元', image: 'https://picsum.photos/seed/case3/400/300' },
{ id: 4, platform: 'TEMU卖家', isPrimary: true, title: '复古水洗 经典重塑', description: '巡演灵感做旧款,粉丝自发应援首选', salesGrowth: '260%', monthlySales: '45W美元', image: 'https://picsum.photos/seed/case1/400/300' },
{ id: 5, platform: 'TikTok卖家', isPrimary: false, title: '冠军荣耀 同款套装', description: '夺冠纪念球衣,球迷珍藏爆款', salesGrowth: '300%', monthlySales: '68W美元', image: 'https://picsum.photos/seed/case2/400/300' },
{ id: 6, platform: 'SHEIN卖家', isPrimary: false, title: '潮流印花 全棉舒适', description: '自制设计印花图案,无尾货压力轻松售', salesGrowth: '180%', monthlySales: '22W美元', image: 'https://picsum.photos/seed/case3/400/300' },
]);
</script>
<template>
<section class="py-10 lg:py-16 bg-gray-50 overflow-hidden">
<div class="max-w-7xl mx-auto px-4 lg:px-6">
<h2 class="text-[clamp(1.5rem,3vw,2.5rem)] font-bold text-gray-900 text-center mb-8 lg:mb-12">用户案例</h2>
</div>
<div class="hidden lg:block overflow-hidden hover:[&>.marquee-track]:[animation-play-state:paused]">
<div class="marquee-track flex gap-8 w-max animate-marquee">
<div
v-for="(testCase, idx) in [...customerCases, ...customerCases]"
:key="'a-' + idx"
class="flex-shrink-0 w-[350px] h-[420px] bg-white rounded-xl overflow-hidden"
>
<div class="relative h-48">
<span
:class="['absolute top-4 left-4', testCase.isPrimary ? 'bg-primary' : 'bg-black', 'text-white px-3 py-1 rounded-full text-sm font-medium']"
>
{{ testCase.platform }}
</span>
<img :src="testCase.image" :alt="testCase.title" class="w-full h-full object-cover" />
</div>
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<div>
<p class="text-gray-500 text-sm">销量增长</p>
<p class="text-2xl lg:text-3xl font-bold text-primary">{{ testCase.salesGrowth }}</p>
</div>
<div class="text-right">
<p class="text-gray-500 text-sm">月销售额</p>
<p class="text-2xl lg:text-3xl font-bold text-primary">{{ testCase.monthlySales }}</p>
</div>
</div>
<h3 class="text-lg lg:text-xl font-bold text-gray-900 mb-2">{{ testCase.title }}</h3>
<p class="text-gray-600 text-sm">{{ testCase.description }}</p>
</div>
</div>
</div>
</div>
<div class="lg:hidden overflow-x-auto pb-4 px-4">
<div class="flex gap-4 w-max">
<div
v-for="(testCase, idx) in customerCases"
:key="'m-' + idx"
class="flex-shrink-0 w-[280px] bg-white rounded-xl overflow-hidden"
>
<div class="relative h-36">
<span
:class="['absolute top-3 left-3', testCase.isPrimary ? 'bg-primary' : 'bg-black', 'text-white px-2 py-1 rounded-full text-xs font-medium']"
>
{{ testCase.platform }}
</span>
<img :src="testCase.image" :alt="testCase.title" class="w-full h-full object-cover" />
</div>
<div class="p-4">
<div class="flex justify-between items-center mb-3">
<div>
<p class="text-gray-500 text-xs">销量增长</p>
<p class="text-xl font-bold text-primary">{{ testCase.salesGrowth }}</p>
</div>
<div class="text-right">
<p class="text-gray-500 text-xs">月销售额</p>
<p class="text-xl font-bold text-primary">{{ testCase.monthlySales }}</p>
</div>
</div>
<h3 class="text-base font-bold text-gray-900 mb-1">{{ testCase.title }}</h3>
<p class="text-gray-600 text-xs">{{ testCase.description }}</p>
</div>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,77 @@
<script setup lang="ts">
const activeFeature = ref(0);
const features = [
{ title: '设计工具', desc: '轻松设计,快速定制', image: '/设计工具.png' },
{ title: '自动化履约', desc: '从生产到配送,全程无忧,准时交付', image: '/自动化履约.png' },
{ title: '全球配送', desc: '全球20+国家本土工厂', image: '/全球配送.png' },
{ title: '店铺集成', desc: '与Shopify、Amazon等平台无缝对接', image: '/店铺集成.png' },
];
const selectFeature = (idx: number) => {
activeFeature.value = idx;
};
let timer: ReturnType<typeof setInterval> | null = null;
const startAutoPlay = () => {
stopAutoPlay();
timer = setInterval(() => {
activeFeature.value = (activeFeature.value + 1) % features.length;
}, 3000);
};
const stopAutoPlay = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
const pauseAutoPlay = () => {
stopAutoPlay();
};
const resumeAutoPlay = () => {
startAutoPlay();
};
onMounted(startAutoPlay);
onUnmounted(stopAutoPlay);
</script>
<template>
<section class="w-full flex flex-col gap-8 lg:gap-10 items-center p-6 lg:p-10">
<div class="text-2xl md:text-4xl lg:text-5xl text-center">用强大功能开启盈利快车道</div>
<div class="flex flex-col lg:flex-row gap-5 w-full max-w-7xl items-stretch">
<div class="flex flex-row lg:flex-col justify-start lg:justify-between gap-3 lg:gap-0 overflow-x-auto lg:overflow-visible"
@mouseenter="pauseAutoPlay"
@mouseleave="resumeAutoPlay"
>
<div
v-for="(feat, idx) in features"
:key="idx"
@click="selectFeature(idx)"
@mouseenter="activeFeature = idx"
class="flex-shrink-0 flex flex-col justify-center gap-2 lg:gap-5 pl-4 lg:pl-5 w-40 lg:w-100 h-20 lg:h-38 rounded-lg cursor-pointer transition-colors duration-300"
:class="activeFeature === idx ? 'bg-primary text-white' : 'bg-inkreach-homepage-2'"
>
<p class="text-xl lg:text-4xl">{{ feat.title }}</p>
<p class="text-sm lg:text-base indent-1">{{ feat.desc }}</p>
</div>
</div>
<div class="flex-1 overflow-hidden rounded-xl relative">
<img
v-for="(feat, idx) in features"
:key="'feat-img-' + idx"
:src="feat.image"
:alt="feat.title"
class="w-full h-full object-contain transition-opacity duration-500 absolute inset-0"
:class="activeFeature === idx ? 'opacity-100' : 'opacity-0 pointer-events-none'"
/>
</div>
</div>
</section>
</template>
@@ -0,0 +1,42 @@
<template>
<section class="w-full bg-inkreach-homepage-1 flex flex-col items-center gap-6 lg:gap-10 p-6 lg:p-10 relative overflow-hidden">
<img
src="/水洗T恤.png"
alt=""
class="hidden lg:block absolute left-[10%] top-2/6 -translate-y-1/2"
/>
<img
src="/帆布袋.png"
alt=""
class="hidden lg:block absolute right-[10%] top-1/4 -translate-y-2/3"
/>
<div class="flex flex-col lg:flex-row gap-2 lg:gap-4 text-3xl md:text-5xl lg:text-7xl font-extrabold text-center mt-4 lg:mt-0">
<div>一站定制</div>
<div>货通全球</div>
</div>
<p class="text-xl md:text-2xl lg:text-4xl text-center">从定制设计至生产发货如此简单</p>
<div class="flex gap-3 text-base lg:text-xl">
<div>0库存</div>
<div>0关税</div>
<div>1件起订</div>
</div>
<div class="relative">
<button
class="text-lg lg:text-xl text-white px-10 lg:px-14 py-3 lg:py-4 rounded-lg hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 pulse-animation bg-primary relative z-10"
>
免费试用
</button>
<img
src="/鸭舌帽.png"
alt=""
class="hidden lg:block absolute left-full -ml-9 top-1/2 -translate-y-1/2"
/>
</div>
<img src="/主视觉.png" class="w-full max-w-5xl" />
</section>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
const {
countryGroups,
activeCountryIdx,
loading,
loadingProducts,
countryPage,
visibleCountries,
canPrevCountry,
canNextCountry,
currentProducts,
fetchCategories,
selectCountry,
} = usePodProducts();
onMounted(() => {
fetchCategories();
});
</script>
<template>
<section class="w-full flex flex-col items-center p-6 lg:p-10 bg-inkreach-homepage-2">
<div class="w-full max-w-7xl flex flex-col gap-8 lg:gap-10">
<div class="text-2xl md:text-4xl lg:text-5xl text-center">全球POD货盘</div>
<div v-if="loading" class="flex justify-center py-8">
<i class="fa-solid fa-spinner fa-spin text-3xl text-primary"></i>
</div>
<template v-else>
<div class="flex items-center justify-center gap-4 lg:gap-10 overflow-x-auto pb-2">
<button
v-if="canPrevCountry"
@click="countryPage--"
class="w-8 h-8 flex-shrink-0 flex items-center justify-center rounded-full border border-gray-300 text-gray-400 hover:border-primary hover:text-primary transition-colors"
>
<i class="fa-solid fa-chevron-left text-xs"></i>
</button>
<button
v-for="country in visibleCountries"
:key="country.id"
@click="selectCountry(countryGroups.indexOf(country))"
class="px-4 lg:px-6 py-2 rounded-lg text-base lg:text-xl transition-colors whitespace-nowrap"
:class="activeCountryIdx === countryGroups.indexOf(country)
? 'bg-primary text-white'
: 'bg-white text-gray-500 hover:text-gray-700'"
>
{{ country.name }}
</button>
<button
v-if="canNextCountry"
@click="countryPage++"
class="w-8 h-8 flex-shrink-0 flex items-center justify-center rounded-full border border-gray-300 text-gray-400 hover:border-primary hover:text-primary transition-colors"
>
<i class="fa-solid fa-chevron-right text-xs"></i>
</button>
</div>
<div class="flex justify-center items-center min-h-[200px]">
<div v-if="loadingProducts" class="flex justify-center py-8">
<i class="fa-solid fa-spinner fa-spin text-2xl text-primary"></i>
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3 lg:gap-5 w-full">
<div
v-for="product in currentProducts"
:key="product.id"
class="bg-white rounded-lg overflow-hidden"
>
<div class="h-[14.25rem] flex items-center justify-center">
<img
v-if="product.image"
:src="product.image"
:alt="product.name"
class="max-w-full max-h-full"
loading="lazy"
/>
<div v-else class="flex items-center justify-center text-gray-300">
<i class="fa-solid fa-image text-4xl"></i>
</div>
</div>
<div class="w-full text-xs lg:text-sm h-8 lg:h-10 flex justify-center items-center px-1 truncate">
<div class="truncate">{{ product.name }}</div>
</div>
</div>
</div>
</div>
<div class="w-full flex justify-center py-6 lg:py-10">
<NuxtLink
to="/product-center"
class="inline-flex items-center gap-2 px-6 lg:px-8 py-2.5 lg:py-3 rounded-lg border-2 border-primary text-primary text-base lg:text-lg font-medium bg-white hover:bg-primary hover:text-white hover:shadow-lg transition-all duration-300 transform hover:-translate-y-0.5"
>
更多产品
<i class="fa-solid fa-arrow-right text-xs"></i>
</NuxtLink>
</div>
</template>
</div>
</section>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
const activeStep = ref(0);
const steps = [
{ num: '1', title: '选择产品', desc: '海量精选产品,满足多元定制需求', image: '/01步骤-选择产品.png' },
{ num: '2', title: '上传设计', desc: '可视化编辑器,轻松实现创意落地', image: '/02步骤-上传设计.png' },
{ num: '3', title: '在线销售', desc: '一键开通店铺,多渠道触达目标客群', image: '/03步骤-在线销售.png' },
{ num: '4', title: '生产配送', desc: '智能化柔性生产,快速响应全球配送', image: '/04步骤-生产配送.png' },
];
const selectStep = (idx: number) => {
activeStep.value = idx;
};
let timer: ReturnType<typeof setInterval> | null = null;
const startAutoPlay = () => {
stopAutoPlay();
timer = setInterval(() => {
activeStep.value = (activeStep.value + 1) % steps.length;
}, 3000);
};
const stopAutoPlay = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
const pauseAutoPlay = () => {
stopAutoPlay();
};
const resumeAutoPlay = () => {
startAutoPlay();
};
onMounted(startAutoPlay);
onUnmounted(stopAutoPlay);
</script>
<template>
<section class="w-full flex flex-col gap-8 lg:gap-10 items-center p-6 lg:p-10">
<div class="text-2xl md:text-4xl lg:text-6xl text-center">4 步开始你的按需定制生意</div>
<div class="flex flex-col lg:flex-row gap-5 w-full max-w-7xl items-stretch">
<div class="flex flex-row lg:flex-col gap-3 lg:gap-10 lg:p-5 lg:w-5/12 overflow-x-auto lg:overflow-visible justify-center"
@mouseenter="pauseAutoPlay"
@mouseleave="resumeAutoPlay"
>
<div
v-for="(step, idx) in steps"
:key="idx"
class="flex-shrink-0 flex flex-col gap-2 lg:gap-5 cursor-pointer group min-w-[140px] lg:min-w-0"
@click="selectStep(idx)"
@mouseenter="activeStep = idx"
>
<p class="text-xl lg:text-3xl font-bold">{{ step.num }} {{ step.title }}</p>
<p class="hidden lg:block indent-10 text-gray-500 text-xl">{{ step.desc }}</p>
<div class="h-0.5 w-full rounded-full bg-gray-200 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="activeStep === idx ? 'w-1/2 bg-primary' : 'w-0 bg-primary'"
></div>
</div>
</div>
<div class="hidden lg:flex items-center pt-6">
<button class="text-xl lg:text-2xl text-white bg-primary rounded-lg px-10 lg:px-14 py-3 lg:py-4 hover:opacity-90 transition-opacity">
立即开始
</button>
</div>
</div>
<div class="flex-1 overflow-hidden rounded-xl relative">
<img
v-for="(step, idx) in steps"
:key="'img-' + idx"
:src="step.image"
:alt="step.title"
class="w-full h-full object-contain transition-opacity duration-500 absolute inset-0"
:class="activeStep === idx ? 'opacity-100' : 'opacity-0 pointer-events-none'"
/>
</div>
</div>
<div class="flex lg:hidden">
<button class="text-xl text-white bg-primary rounded-lg px-10 py-3 hover:opacity-90 transition-opacity">
立即开始
</button>
</div>
</section>
</template>
@@ -0,0 +1,32 @@
<template>
<section class="w-full bg-inkreach-homepage-2 flex flex-col justify-center items-center gap-6 lg:gap-10 p-6 lg:p-10 py-12 lg:py-0 lg:h-138">
<div class="flex gap-2 text-2xl md:text-4xl lg:text-5xl text-center">全球5000+卖家信赖我们的平台</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 lg:gap-14 w-full max-w-5xl">
<div
v-for="item in stats"
:key="item.label"
class="bg-white overflow-hidden rounded-lg"
>
<div class="bg-inkreach-homepage-3">
<div class="text-lg lg:text-2xl flex flex-col gap-1 lg:gap-2 p-3 lg:p-5">
<div>{{ item.value }}</div>
<div>{{ item.label }}</div>
</div>
</div>
<div class="relative -top-6 lg:-top-10">
<img :src="item.icon" :alt="item.label" />
</div>
</div>
</div>
</section>
</template>
<script setup>
const stats = [
{ value: '1000万+', label: '订单履约', icon: '/订单履约.png' },
{ value: '20+', label: '国家配送', icon: '/国家配送.png' },
{ value: '500+', label: '产品生产', icon: '/产品生产.png' },
{ value: '24H', label: '急速发货', icon: '/极速发货.png' },
];
</script>
@@ -0,0 +1,32 @@
<template>
<section class="py-10 lg:py-16 px-4 lg:px-6 bg-inkreach-homepage-2">
<div class="max-w-7xl mx-auto">
<h2 class="text-2xl md:text-4xl lg:text-5xl font-bold text-center mb-8 lg:mb-10">
为什么选择<span
style="background: linear-gradient(90deg, #000000, #ec6000); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent;"
>inkreach?</span>
</h2>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
<div
v-for="item in reasons"
:key="item.title"
class="bg-white rounded-xl p-6 lg:p-10 text-center flex flex-col justify-center items-center gap-3"
>
<div class="rounded-full flex items-center justify-center">
<img :src="item.icon" :alt="item.title" class="w-8 h-8 lg:w-auto lg:h-auto" />
</div>
<h3 class="text-lg lg:text-2xl font-bold text-gray-900">{{ item.title }}</h3>
</div>
</div>
</div>
</section>
</template>
<script setup>
const reasons = [
{ title: '零库存', icon: '/零库存图标.png' },
{ title: '快速生产', icon: '/快速生产图标.png' },
{ title: '优质产品', icon: '/优质产品图标.png' },
{ title: '全球配送', icon: '/全球配送图标.png' },
];
</script>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { SolutionColumn, NavBanner } from '~/composables/useNavData';
defineProps<{
columns: SolutionColumn[];
banner: NavBanner | null;
visible: boolean;
}>();
</script>
<template>
<Transition name="nav-dropdown">
<div
v-if="visible && columns.length > 0"
class="absolute left-0 top-full bg-white rounded-lg shadow-[0_4px_25px_rgba(179,185,191,0.3)] border border-gray-200 flex z-50 p-6 gap-6"
>
<div class="flex gap-6">
<div
v-for="col in columns"
:key="col.id"
class="min-w-[150px]"
>
<h3 class="text-[15px] font-bold text-gray-800 pb-3 mb-3 border-b border-gray-200 whitespace-nowrap">
{{ col.title }}
</h3>
<ul class="space-y-2">
<li v-for="link in col.links" :key="link.id">
<a
:href="link.link"
target="_blank"
class="text-[14px] text-gray-600 hover:text-primary transition-colors flex items-center gap-1.5"
>
{{ link.title }}
<span
v-if="link.tag === 'hot'"
class="text-[10px] text-white bg-red-500 px-1.5 py-0.5 rounded leading-none"
>HOT</span>
<span
v-if="link.tag === 'new'"
class="text-[10px] text-white bg-green-500 px-1.5 py-0.5 rounded leading-none"
>NEW</span>
</a>
</li>
</ul>
</div>
</div>
<div v-if="banner" class="shrink-0 self-start">
<a
:href="banner.link"
target="_blank"
class="block w-[180px] h-[140px] rounded-lg overflow-hidden group"
>
<img
:src="banner.image"
:alt="banner.title"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</a>
</div>
</div>
</Transition>
</template>
<style scoped>
.nav-dropdown-enter-active,
.nav-dropdown-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.nav-dropdown-enter-from,
.nav-dropdown-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import type { RecommendCategory } from '~/composables/useNavData';
const props = defineProps<{
categories: RecommendCategory[];
activeIndex: number;
visible: boolean;
}>();
const emit = defineEmits<{
'update:activeIndex': [index: number];
}>();
function onCategoryHover(idx: number): void {
emit('update:activeIndex', idx);
}
const activeCategory = computed(
() => props.categories[props.activeIndex] ?? null,
);
</script>
<template>
<Transition name="nav-dropdown">
<div
v-if="visible && categories.length > 0"
class="absolute left-0 top-full bg-white rounded-lg shadow-[0_4px_25px_rgba(179,185,191,0.3)] border border-gray-200 flex z-50 overflow-hidden"
>
<ul class="w-[240px] border-r border-gray-200 py-3 shrink-0">
<li
v-for="(cat, idx) in categories"
:key="cat.id"
class="flex items-center justify-between px-6 py-4 cursor-pointer text-base transition-colors"
:class="idx === activeIndex
? 'pl-8 bg-inkreach-homepage-1 text-primary font-medium'
: 'text-gray-600 hover:text-primary'"
@mouseenter="onCategoryHover(idx)"
>
<span>{{ cat.title }}</span>
<i class="fa-solid fa-chevron-right text-xs opacity-40"></i>
</li>
</ul>
<div class="p-6 w-[720px]">
<p class="text-base text-gray-800 font-medium mb-4">
{{ activeCategory?.title }}
</p>
<div class="grid grid-cols-2 gap-4">
<a
v-for="item in activeCategory?.items ?? []"
:key="item.id"
:href="item.link"
target="_blank"
class="group block rounded-lg overflow-hidden"
>
<div class="w-full h-[130px] rounded-lg overflow-hidden bg-inkreach-homepage-1">
<img
:src="item.image"
:alt="item.title"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
<p class="mt-2 text-[15px] text-gray-700 group-hover:text-primary transition-colors truncate">
{{ item.title }}
</p>
</a>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.nav-dropdown-enter-active,
.nav-dropdown-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.nav-dropdown-enter-from,
.nav-dropdown-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Product } from '~/composables/useProductCenter';
import { sortTagsByGroup } from '~/composables/useProductCenter';
const props = defineProps<{
product: Product;
showCountry?: boolean;
}>();
const sortedTags = computed(() => sortTagsByGroup(props.product.tags).slice(0, 4));
function formatPrice(price: string | null): string {
if (!price) return '—';
const num = Number(price);
if (!Number.isFinite(num)) return '—';
return `¥${num.toFixed(2)}`;
}
</script>
<template>
<article class="product-card">
<div class="product-media">
<img
v-if="product.image"
:src="product.image"
:alt="product.name"
class="product-img"
loading="lazy"
/>
<div v-else class="placeholder">
<i class="fa-solid fa-image"></i>
</div>
</div>
<div class="product-info">
<h3 class="product-title" :title="product.name">{{ product.name }}</h3>
<div v-if="sortedTags.length > 0 || showCountry" class="chips">
<span
v-for="tag in sortedTags"
:key="tag.id"
class="chip"
:style="{ color: tag.fontColor || tag.color || '#ff6a00', background: (tag.color || '#ff6a00') + '4d', borderColor: (tag.color || '#ff6a00') + '99' }"
>
{{ tag.name }}
</span>
<span v-if="showCountry" class="chip country-chip">
<img
v-if="product.country.icon"
:src="product.country.icon"
:alt="product.country.name"
class="country-flag"
/>
{{ product.country.name }}
</span>
</div>
<div class="price">{{ formatPrice(product.price) }}</div>
</div>
</article>
</template>
<style scoped>
.product-card {
overflow: hidden;
border-radius: 12px;
background: #fff;
border: 1px solid #eee;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.product-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 28px rgba(17, 17, 17, 0.06);
}
.product-media {
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 1 / 1;
background: linear-gradient(180deg, #f8f8f8, #f0f0f0);
overflow: hidden;
}
.product-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.placeholder {
color: #ccc;
font-size: 32px;
}
.product-info {
padding: 14px;
}
.product-title {
margin: 0 0 12px;
font-size: 19px;
line-height: 1.4;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #111;
}
.chips {
display: flex;
gap: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 8px;
border-radius: 5px;
color: #ff6a00;
background: #fff2e8;
font-size: 14px;
white-space: nowrap;
}
.country-chip {
color: #555;
background: #f3f4f6;
font-size: 14px;
}
.country-flag {
width: 16px;
height: 12px;
border-radius: 2px;
object-fit: cover;
margin-right: 4px;
}
.price {
color: #222;
font-size: 20px;
font-weight: 700;
}
</style>
@@ -0,0 +1,84 @@
<template>
<article class="skeleton-card">
<div class="skeleton-media"></div>
<div class="skeleton-info">
<div class="skeleton-line title"></div>
<div class="skeleton-chips">
<span class="skeleton-chip"></span>
<span class="skeleton-chip"></span>
<span class="skeleton-chip"></span>
<span class="skeleton-chip"></span>
</div>
<div class="skeleton-line price"></div>
</div>
</article>
</template>
<style scoped>
.skeleton-card {
overflow: hidden;
border-radius: 12px;
background: #fff;
border: 1px solid transparent;
}
.skeleton-media,
.skeleton-line,
.skeleton-chip {
position: relative;
overflow: hidden;
background: #eee;
}
.skeleton-media {
width: 100%;
aspect-ratio: 1 / 1;
}
.skeleton-media::after,
.skeleton-line::after,
.skeleton-chip::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, #f7f7f7, transparent);
animation: shimmer 1.25s infinite;
transform: translateX(-100%);
}
.skeleton-info {
padding: 12px;
}
.skeleton-line {
height: 22px;
border-radius: 6px;
}
.skeleton-line.title {
width: 90%;
margin-bottom: 11px;
}
.skeleton-line.price {
width: 35%;
}
.skeleton-chips {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
.skeleton-chip {
width: 46px;
height: 18px;
border-radius: 5px;
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
</style>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { Country } from '~/composables/useProductCenter';
defineProps<{
countries: Country[];
activeCountryId: string | null;
}>();
const emit = defineEmits<{
'update:activeCountryId': [id: string | null];
}>();
function selectCountry(id: string | null): void {
emit('update:activeCountryId', id);
}
</script>
<template>
<div class="country-bar">
<button
type="button"
class="filter-pill"
:class="{ active: activeCountryId === null }"
@click="selectCountry(null)"
>
全部
</button>
<button
v-for="country in countries"
:key="country.id"
type="button"
class="filter-pill"
:class="{ active: activeCountryId === country.id }"
@click="selectCountry(country.id)"
>
<img
v-if="country.icon"
:src="country.icon"
:alt="country.name"
class="flag"
/>
{{ country.name }}
</button>
</div>
</template>
<style scoped>
.country-bar {
display: flex;
align-items: center;
gap: 10px;
overflow-x: auto;
}
.filter-pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 40px;
padding: 0 16px;
border-radius: 8px;
border: 1px solid #eee;
background: #fff;
font-size: 16px;
color: #555;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s ease;
flex-shrink: 0;
}
.filter-pill:hover {
border-color: #ddd;
color: #222;
}
.filter-pill.active {
color: #ff6a00;
border-color: #ff6a00;
background: #fff2e8;
}
.flag {
width: 18px;
height: 12px;
border-radius: 2px;
object-fit: cover;
}
</style>
@@ -0,0 +1,108 @@
<script setup lang="ts">
const keyword = defineModel<string>('keyword', { default: '' });
const emit = defineEmits<{
search: [];
}>();
const localKeyword = ref(keyword.value);
watch(keyword, (v) => {
localKeyword.value = v;
});
function onSearch(): void {
keyword.value = localKeyword.value.trim();
emit('search');
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
event.preventDefault();
onSearch();
}
}
function clearKeyword(): void {
localKeyword.value = '';
keyword.value = '';
emit('search');
}
</script>
<template>
<div class="search-box">
<input
v-model="localKeyword"
type="text"
placeholder="搜索商品"
@keydown="onKeydown"
/>
<button v-if="localKeyword" type="button" class="clear-btn" @click="clearKeyword">
<i class="fa-solid fa-times"></i>
</button>
<button type="button" class="search-btn" @click="onSearch">
<i class="fa-solid fa-magnifying-glass"></i>
</button>
</div>
</template>
<style scoped>
.search-box {
display: flex;
align-items: stretch;
height: 38px;
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.search-box:focus-within {
border-color: #ff6a00;
}
.search-box input {
width: 200px;
padding: 0 12px;
font-size: 14px;
border: 0;
outline: none;
background: transparent;
color: #222;
}
.search-box input::placeholder {
color: #aaa;
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
border: 0;
background: transparent;
color: #999;
cursor: pointer;
}
.clear-btn:hover {
color: #555;
}
.search-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
border: 0;
background: #ff6a00;
color: #fff;
cursor: pointer;
transition: opacity 0.2s ease;
}
.search-btn:hover {
opacity: 0.9;
}
</style>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import type { Product } from '~/composables/useProductCenter';
const props = defineProps<{
products: Product[];
loading: boolean;
showCountry?: boolean;
}>();
const skeletonCount = 12;
</script>
<template>
<div>
<div v-if="loading" class="product-grid">
<ProductCardSkeleton
v-for="n in skeletonCount"
:key="`skeleton-${n}`"
/>
</div>
<div v-else-if="products.length === 0" class="empty-state">
<div class="empty-art">
<div class="empty-box"></div>
<div class="empty-lid"></div>
<div class="empty-spark spark-1"></div>
<div class="empty-spark spark-2"></div>
</div>
<h2>暂无匹配商品</h2>
<p>试试清空筛选条件或切换其他分类看看</p>
</div>
<div v-else class="product-grid">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
:show-country="showCountry"
/>
</div>
</div>
</template>
<style scoped>
.product-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 28px;
}
.empty-state {
display: grid;
place-items: center;
min-height: 480px;
padding: 80px 0;
text-align: center;
}
.empty-art {
position: relative;
width: 220px;
height: 176px;
margin: 0 auto 28px;
}
.empty-box {
position: absolute;
left: 35px;
top: 42px;
width: 150px;
height: 94px;
border-radius: 18px;
background: #f2f2f2;
transform: perspective(400px) rotateX(58deg) rotateZ(-8deg);
}
.empty-lid {
position: absolute;
left: 48px;
top: 20px;
width: 124px;
height: 64px;
border-radius: 16px;
background: #e8e8e8;
transform: rotate(-8deg);
}
.empty-spark {
position: absolute;
border-radius: 999px;
background: #ff6a00;
}
.spark-1 {
left: 21px;
top: 52px;
width: 12px;
height: 12px;
}
.spark-2 {
right: 26px;
top: 18px;
width: 8px;
height: 8px;
}
.empty-state h2 {
margin: 0 0 10px;
font-size: 24px;
font-weight: 600;
color: #111;
}
.empty-state p {
margin: 0;
color: #777;
font-size: 16px;
}
@media (max-width: 900px) {
.product-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
}
}
</style>
@@ -0,0 +1,249 @@
<script setup lang="ts">
const props = defineProps<{
total: number;
page: number;
pageSize: number;
}>();
const emit = defineEmits<{
'update:page': [page: number];
'update:pageSize': [size: number];
}>();
const totalPages = computed(() => {
if (props.total <= 0) return 1;
return Math.max(1, Math.ceil(props.total / props.pageSize));
});
const pageNumbers = computed<(number | string)[]>(() => {
const total = totalPages.value;
const current = props.page;
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: (number | string)[] = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) pages.push('...');
for (let i = start; i <= end; i++) pages.push(i);
if (end < total - 1) pages.push('...');
pages.push(total);
return pages;
});
const canPrev = computed(() => props.page > 1);
const canNext = computed(() => props.page < totalPages.value);
const gotoInput = ref('');
function goPrev(): void {
if (canPrev.value) emit('update:page', props.page - 1);
}
function goNext(): void {
if (canNext.value) emit('update:page', props.page + 1);
}
function goToPage(page: number): void {
if (page < 1 || page > totalPages.value || page === props.page) return;
emit('update:page', page);
}
function onPageSizeChange(event: Event): void {
const target = event.target as HTMLSelectElement;
const size = Number(target.value);
if (!Number.isFinite(size) || size <= 0) return;
emit('update:pageSize', size);
}
function onGoto(): void {
const value = Number(gotoInput.value);
if (!Number.isFinite(value)) return;
const clamped = Math.min(totalPages.value, Math.max(1, Math.floor(value)));
gotoInput.value = '';
goToPage(clamped);
}
function onGotoKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
event.preventDefault();
onGoto();
}
}
</script>
<template>
<div class="pagination">
<div class="total-text">
总计 <span>{{ total }}</span> 个产品
</div>
<div class="page-buttons">
<button
type="button"
class="nav-btn"
:disabled="!canPrev"
@click="goPrev"
>
<i class="fa-solid fa-chevron-left"></i>
</button>
<button
v-for="(item, idx) in pageNumbers"
:key="`page-${idx}-${item}`"
type="button"
class="page-btn"
:class="{
current: item === page,
dots: item === '...',
}"
:disabled="item === '...'"
@click="typeof item === 'number' && goToPage(item)"
>
{{ item }}
</button>
<button
type="button"
class="nav-btn"
:disabled="!canNext"
@click="goNext"
>
<i class="fa-solid fa-chevron-right"></i>
</button>
</div>
<div class="right-controls">
<select :value="pageSize" class="size-select" @change="onPageSizeChange">
<option :value="12">12 / </option>
<option :value="20">20 / </option>
<option :value="40">40 / </option>
</select>
<span class="goto-label">导航至</span>
<input
v-model="gotoInput"
type="number"
min="1"
:max="totalPages"
class="goto-input"
@keydown="onGotoKeydown"
/>
<button type="button" class="goto-btn" @click="onGoto">Go</button>
</div>
</div>
</template>
<style scoped>
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
padding: 16px 24px;
font-size: 14px;
color: #777;
}
.total-text span {
font-weight: 600;
color: #222;
}
.page-buttons {
display: flex;
align-items: center;
gap: 4px;
}
.nav-btn,
.page-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
border-radius: 6px;
border: 1px solid #eee;
background: #fff;
color: #555;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.nav-btn:hover:not(:disabled),
.page-btn:hover:not(.dots):not(.current) {
border-color: #ff6a00;
color: #ff6a00;
}
.nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-btn.current {
background: #ff6a00;
border-color: #ff6a00;
color: #fff;
}
.page-btn.dots {
border-color: transparent;
cursor: default;
}
.right-controls {
display: flex;
align-items: center;
gap: 8px;
}
.size-select {
height: 32px;
padding: 0 8px;
border: 1px solid #eee;
border-radius: 6px;
background: #fff;
font-size: 14px;
color: #555;
outline: none;
}
.goto-label {
color: #999;
}
.goto-input {
width: 48px;
height: 32px;
padding: 0 8px;
border: 1px solid #eee;
border-radius: 6px;
background: #fff;
font-size: 14px;
color: #222;
outline: none;
text-align: center;
}
.goto-input:focus {
border-color: #ff6a00;
}
.goto-btn {
height: 32px;
padding: 0 12px;
border: 1px solid #ff6a00;
border-radius: 6px;
background: transparent;
color: #ff6a00;
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
}
.goto-btn:hover {
background: #ff6a00;
color: #fff;
}
</style>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import type { Category } from '~/composables/useProductCenter';
const props = defineProps<{
categories: Category[];
activeCategoryId: string | null;
}>();
const emit = defineEmits<{
'update:activeCategoryId': [id: string | null];
}>();
const expanded = ref<Record<string, boolean>>({});
function toggleExpand(id: string): void {
expanded.value[id] = !expanded.value[id];
}
function isExpanded(id: string): boolean {
return Boolean(expanded.value[id]);
}
function selectCategory(id: string | null): void {
emit('update:activeCategoryId', id);
}
function shouldAutoExpand(id: string): boolean {
if (props.activeCategoryId === null) return false;
if (props.activeCategoryId === id) return true;
const cat = props.categories.find((c) => c.id === id);
if (!cat) return false;
return cat.children.some((c) => c.id === props.activeCategoryId);
}
watch(
() => props.categories,
(cats) => {
for (const c of cats) {
if (shouldAutoExpand(c.id)) {
expanded.value[c.id] = true;
}
}
},
{ immediate: true },
);
</script>
<template>
<aside class="product-sidebar">
<div class="category-list">
<button
type="button"
class="cat-row"
:class="{ active: activeCategoryId === null }"
@click="selectCategory(null)"
>
<span class="cat-icon"></span>
<span class="cat-name">全部商品</span>
</button>
<template v-for="cat in categories" :key="cat.id">
<button
type="button"
class="cat-row"
:class="{ active: activeCategoryId === cat.id }"
@click="cat.children.length > 0 ? toggleExpand(cat.id) : selectCategory(cat.id)"
>
<span class="cat-icon"></span>
<span class="cat-name">{{ cat.name }}</span>
<span v-if="cat.children.length > 0" class="chevron" :class="{ expanded: isExpanded(cat.id) }"></span>
</button>
<Transition name="subtree">
<div v-if="cat.children.length > 0 && isExpanded(cat.id)" class="subtree">
<button
v-for="child in cat.children"
:key="child.id"
type="button"
class="sub-row"
:class="{ active: activeCategoryId === child.id }"
@click="selectCategory(child.id)"
>
{{ child.name }}
</button>
</div>
</Transition>
</template>
<div v-if="categories.length === 0" class="empty-cat">暂无分类</div>
</div>
</aside>
</template>
<style scoped>
.product-sidebar {
position: relative;
padding-top: 26px;
background: #ffffff;
min-height: 100%;
}
.category-list {
width: 216px;
margin: 0 auto;
}
.cat-row {
display: flex;
align-items: center;
height: 45px;
margin-bottom: 12px;
padding: 0 10px;
border-radius: 8px;
font-size: 16px;
color: #222;
transition: background 0.2s ease, color 0.2s ease;
border: 0;
background: transparent;
cursor: pointer;
width: 100%;
}
.cat-row:hover {
background: #f5f5f5;
}
.cat-row.active {
color: #ff6a00;
background: #fff2e8;
}
.cat-icon {
position: relative;
width: 24px;
height: 24px;
margin-right: 8px;
color: currentColor;
flex-shrink: 0;
}
.cat-icon::before {
content: '';
position: absolute;
inset: 4px 5px;
border: 2px solid currentColor;
border-radius: 7px 7px 4px 4px;
}
.cat-icon::after {
content: '';
position: absolute;
left: 8px;
top: 2px;
width: 8px;
height: 7px;
border: 2px solid currentColor;
border-bottom: 0;
border-radius: 7px 7px 0 0;
}
.cat-name {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex-shrink: 0;
width: 8px;
height: 8px;
border-right: 1.8px solid currentColor;
border-bottom: 1.8px solid currentColor;
transform: rotate(45deg) translateY(-2px);
transition: transform 0.2s ease;
}
.chevron.expanded {
transform: rotate(225deg) translateY(-2px);
}
.subtree {
position: relative;
margin: -2px 0 12px;
padding: 0 0 0 42px;
}
.subtree::before {
content: '';
position: absolute;
left: 22px;
top: 0;
bottom: 0;
width: 1px;
background: #ddd;
}
.sub-row {
position: relative;
display: flex;
align-items: center;
width: 100%;
height: 45px;
margin-bottom: 10px;
padding-left: 10px;
border-radius: 8px;
color: #555;
font-size: 15px;
border: 0;
background: transparent;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.sub-row::before {
content: '';
position: absolute;
left: -16px;
top: 50%;
width: 6px;
height: 1px;
background: #ddd;
}
.sub-row:hover {
background: #f5f5f5;
}
.sub-row.active {
color: #ff6a00;
background: #fff2e8;
}
.empty-cat {
padding: 40px 0;
text-align: center;
color: #999;
font-size: 14px;
}
.subtree-enter-active,
.subtree-leave-active {
transition: all 0.2s ease;
overflow: hidden;
}
.subtree-enter-from,
.subtree-leave-to {
opacity: 0;
max-height: 0;
}
.subtree-enter-to,
.subtree-leave-from {
max-height: 600px;
}
</style>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import type { Tag } from '~/composables/useProductCenter';
const props = defineProps<{
tags: Tag[];
selectedTagIds: string[];
}>();
const emit = defineEmits<{
'toggle-tag': [id: string];
}>();
const dropdownOpen = ref(false);
const dropdownRef = ref<HTMLElement | null>(null);
function toggleDropdown(): void {
dropdownOpen.value = !dropdownOpen.value;
}
function toggleTag(id: string): void {
emit('toggle-tag', id);
}
function isSelected(id: string): boolean {
return props.selectedTagIds.includes(id);
}
function onClickOutside(event: MouseEvent): void {
if (dropdownRef.value && !dropdownRef.value.contains(event.target as Node)) {
dropdownOpen.value = false;
}
}
onMounted(() => {
document.addEventListener('click', onClickOutside);
});
onUnmounted(() => {
document.removeEventListener('click', onClickOutside);
});
const label = computed(() => {
if (props.selectedTagIds.length === 0) return '标签';
if (props.selectedTagIds.length === 1) {
return props.tags.find((t) => t.id === props.selectedTagIds[0])?.name ?? '标签';
}
return `标签 (${props.selectedTagIds.length})`;
});
</script>
<template>
<div ref="dropdownRef" class="filter-pill-wrapper">
<button
type="button"
class="filter-pill"
:class="{ active: selectedTagIds.length > 0 }"
@click="toggleDropdown"
>
{{ label }}
<span class="chevron" :class="{ expanded: dropdownOpen }"></span>
</button>
<Transition name="dropdown">
<div v-if="dropdownOpen" class="dropdown-menu">
<button
v-for="tag in tags"
:key="tag.id"
type="button"
class="dropdown-item"
@click="toggleTag(tag.id)"
>
<span class="tag-label">{{ tag.name }}</span>
<span class="check-box" :class="{ checked: isSelected(tag.id) }">
<i v-if="isSelected(tag.id)" class="fa-solid fa-check text-[10px]"></i>
</span>
</button>
</div>
</Transition>
</div>
</template>
<style scoped>
.filter-pill-wrapper {
position: relative;
}
.filter-pill {
display: inline-flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 16px;
border-radius: 8px;
border: 1px solid #eee;
background: #fff;
font-size: 15px;
color: #222;
cursor: pointer;
transition: border-color 0.2s ease;
}
.filter-pill:hover {
border-color: #ddd;
}
.filter-pill.active {
color: #ff6a00;
border-color: #ff6a00;
}
.chevron {
width: 8px;
height: 8px;
border-right: 1.8px solid currentColor;
border-bottom: 1.8px solid currentColor;
transform: rotate(45deg) translateY(-2px);
transition: transform 0.2s ease;
}
.chevron.expanded {
transform: rotate(225deg) translateY(-2px);
}
.dropdown-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 180px;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
box-shadow: 0 8px 28px rgba(17, 17, 17, 0.08);
padding: 6px 0;
z-index: 20;
}
.dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
text-align: left;
padding: 8px 16px;
font-size: 14px;
color: #555;
border: 0;
background: transparent;
cursor: pointer;
transition: background 0.15s ease;
}
.dropdown-item:hover {
background: #f7f7f7;
}
.tag-label {
flex: 1;
text-align: left;
}
.check-box {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: 1.5px solid #ccc;
border-radius: 4px;
color: #fff;
flex-shrink: 0;
transition: all 0.15s ease;
}
.check-box.checked {
background: #ff6a00;
border-color: #ff6a00;
}
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.15s ease;
overflow: hidden;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
+138
View File
@@ -0,0 +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,
};
}
@@ -0,0 +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,
};
}
@@ -0,0 +1,315 @@
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;
}
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<T> {
data: T;
success: boolean;
}
function mapCategory(node: BackendCategoryNode): Category {
return {
id: node.id,
name: node.categoryName,
icon: node.categoryIcon,
parentId: node.parentCategoryId,
children: node.children.map(mapCategory),
};
}
function mapCountry(c: BackendCountry): Country {
return {
id: c.id,
name: c.countryName,
icon: c.countryIcon,
};
}
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;
});
}
function mapProduct(p: BackendProduct): Product {
return {
id: p.id,
name: p.goodName,
priority: p.goodPriority,
image: p.image,
price: p.price,
country: mapCountry(p.country),
category: {
id: p.category.id,
name: p.category.categoryName,
icon: p.category.categoryIcon,
parentId: null,
children: [],
},
tag: p.tag ? mapTag(p.tag) : null,
tags: p.tags ? p.tags.map(mapTag) : [],
};
}
export function useProductCenter() {
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(mapCategory);
countries.value = cos.data.map(mapCountry);
}
} catch (error) {
console.error('Failed to load filters:', error);
}
}
async function fetchProducts(): 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 (query.tagIds.length > 0) params.tagIds = query.tagIds.join(',');
if (query.keyword?.trim()) params.keyword = query.keyword.trim();
const response = await $fetch<BackendEnvelope<BackendPaginatedProducts>>(
'/api/backend/goods',
{ params },
);
products.value = response.data.items.map(mapProduct);
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 {
const idx = query.tagIds.indexOf(id);
if (idx >= 0) {
query.tagIds.splice(idx, 1);
} else {
query.tagIds.push(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,
};
}
+15
View File
@@ -0,0 +1,15 @@
<template>
<div class="w-full">
<AppHeader />
<HeroBanner />
<TrustSection />
<StepProcess />
<PodProducts />
<FeatureCards />
<WhyInkReach />
<CompanyProfile />
<CustomerCases />
<CtaBanner />
<AppFooter />
</div>
</template>
+387
View File
@@ -0,0 +1,387 @@
<script setup lang="ts">
import type { Tag, Country } from '~/composables/useProductCenter';
import { sortTagsByGroup } from '~/composables/useProductCenter';
useSeoMeta({
title: '产品中心 | InkReach',
description:
'InkReach 产品中心:按品类和国家筛选全球 POD 定制商品,支持关键词搜索、分页浏览与多维筛选。',
});
const stickyTopRef = ref<HTMLElement | null>(null);
const stickySpacerHeight = ref(152);
let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
if (!stickyTopRef.value) return;
const updateHeight = () => {
if (stickyTopRef.value) {
stickySpacerHeight.value = stickyTopRef.value.offsetHeight;
}
};
updateHeight();
resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(stickyTopRef.value);
});
onBeforeUnmount(() => {
resizeObserver?.disconnect();
});
const {
categories,
countries,
tags,
products,
total,
loading,
query,
loadFilters,
fetchProducts,
setCategory,
setCountry,
setTag,
clearTags,
setKeyword,
setPage,
setPageSize,
} = useProductCenter();
const searchKeyword = ref('');
await loadFilters();
await fetchProducts();
watch(
() => [
query.countryId,
query.categoryId,
[...query.tagIds],
query.keyword,
query.page,
query.pageSize,
],
() => {
fetchProducts();
},
);
function onSidebarSelect(id: string | null): void {
setCategory(id);
}
function onCountrySelect(id: string | null): void {
setCountry(id);
}
function onToggleTag(id: string): void {
setTag(id);
}
function onSearch(): void {
setKeyword(searchKeyword.value);
}
function onPageChange(page: number): void {
setPage(page);
if (typeof window !== 'undefined') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
function onPageSizeChange(size: number): void {
setPageSize(size);
}
function clearAllFilters(): void {
setCategory(null);
setCountry(null);
clearTags();
searchKeyword.value = '';
setKeyword('');
}
const activeTags = computed(() => {
const list = query.tagIds
.map((id) => tags.value.find((t: Tag) => t.id === id))
.filter(Boolean) as Tag[];
return sortTagsByGroup(list);
});
const activeCountryName = computed(() => {
if (!query.countryId) return null;
return countries.value.find((c: Country) => c.id === query.countryId)?.name ?? null;
});
const showCountry = computed(() => !query.countryId);
const hasActiveFilters = computed(() => {
return query.tagIds.length > 0 || Boolean(query.countryId || query.keyword);
});
</script>
<template>
<div class="product-page">
<AppHeader />
<main class="content">
<aside class="sidebar-area">
<ProductSidebar
:categories="categories"
:active-category-id="query.categoryId ?? null"
@update:active-category-id="onSidebarSelect"
/>
</aside>
<section class="main-area">
<div ref="stickyTopRef" class="sticky-top">
<div class="country-section">
<ProductCountryFilter
:countries="countries"
:active-country-id="query.countryId ?? null"
@update:active-country-id="onCountrySelect"
/>
</div>
<div class="filter-section">
<div class="filter-left">
<ProductTagFilter
:tags="tags"
:selected-tag-ids="query.tagIds"
@toggle-tag="onToggleTag"
/>
</div>
<div class="selected-tags">
<span
v-for="tag in activeTags"
:key="'tag-' + tag.id"
class="sel-tag"
:style="{ color: tag.fontColor || tag.color || '#ff6a00', background: (tag.color || '#ff6a00') + '4d', borderColor: (tag.color || '#ff6a00') + '99' }"
>
{{ tag.name }}
<button type="button" class="tag-close" @click="onToggleTag(tag.id)"></button>
</span>
<span v-if="query.keyword" class="sel-tag">
"{{ query.keyword }}"
<button type="button" class="tag-close" @click="searchKeyword = ''; onSearch()"></button>
</span>
<span v-if="activeCountryName" class="sel-tag">
{{ activeCountryName }}
<button type="button" class="tag-close" @click="onCountrySelect(null)"></button>
</span>
</div>
<div class="filter-right">
<ProductFilterBar
v-model:keyword="searchKeyword"
@search="onSearch"
/>
</div>
</div>
</div>
<div class="sticky-spacer" :style="{ height: stickySpacerHeight + 'px' }"></div>
<ProductGrid :products="products" :loading="loading" :show-country="showCountry" />
<div v-if="!loading && products.length === 0 && hasActiveFilters" class="clear-all-area">
<button type="button" class="clear-all-btn" @click="clearAllFilters">
清空筛选
</button>
</div>
<ProductPagination
v-if="!loading && total > 0"
:total="total"
:page="query.page"
:page-size="query.pageSize"
@update:page="onPageChange"
@update:page-size="onPageSizeChange"
/>
</section>
</main>
</div>
</template>
<style scoped>
.product-page {
width: 100%;
min-height: 100vh;
background: #fff;
padding-top: 80px;
}
.content {
display: flex;
}
.sidebar-area {
position: fixed;
left: 0;
top: 80px;
bottom: 0;
width: 240px;
border-right: 1px solid #eee;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.sidebar-area::-webkit-scrollbar {
display: none;
}
.main-area {
min-width: 0;
background: #fff;
width: 100%;
margin-left: 240px;
padding: 0 32px 24px;
}
.sticky-spacer {
height: 152px;
}
.sticky-top {
position: fixed;
left: 240px;
right: 0;
top: 80px;
z-index: 10;
background: #fff;
padding: 20px 32px 0;
}
.country-section {
padding: 0 0 14px;
border-bottom: 1px solid #eee;
}
.filter-section {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0;
}
.filter-left {
flex-shrink: 0;
}
.selected-tags {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
flex-wrap: wrap;
}
.sel-tag {
display: inline-flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid;
font-size: 13px;
white-space: nowrap;
}
.tag-close {
position: relative;
width: 14px;
height: 14px;
border: 0;
background: transparent;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s ease;
}
.tag-close:hover {
opacity: 1;
}
.tag-close::before,
.tag-close::after {
content: '';
position: absolute;
left: 6px;
top: 1px;
width: 1.5px;
height: 12px;
background: currentColor;
}
.tag-close::before {
transform: rotate(45deg);
}
.tag-close::after {
transform: rotate(-45deg);
}
.filter-right {
flex-shrink: 0;
}
.clear-all-area {
display: flex;
justify-content: center;
padding: 40px 0;
}
.clear-all-btn {
height: 44px;
padding: 0 26px;
border: 0;
border-radius: 10px;
background: #ff6a00;
color: #fff;
font-size: 16px;
cursor: pointer;
transition: opacity 0.2s ease;
}
.clear-all-btn:hover {
opacity: 0.9;
}
@media (max-width: 900px) {
.sidebar-area {
position: relative;
width: 100%;
top: 0;
border-right: 0;
border-bottom: 1px solid #eee;
overflow-x: auto;
}
.main-area {
margin-left: 0;
}
.sticky-top {
position: relative;
left: 0;
top: 0;
}
.sticky-spacer {
display: none;
}
.filter-section {
flex-wrap: wrap;
}
.filter-right {
width: 100%;
}
}
</style>