fix: convert apps/website from submodule to regular directory
This commit is contained in:
@@ -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>
|
||||
@@ -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">服务于数千家跨境独立站、TikTok、TEMU、SHEIN等平台商家, 提供从海外生产至末端物流的一站式供应链服务, 让电商创业者专注增长, 无惧后端履约。</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user