feat(product-center): implement Figma-designed UI, upload module, and website tests
- Redesign website homepage & product-center per Figma (fonts, logos, hero/footer/customer cases) - Add API upload module (multer) with static serving for uploads/public assets - Add OriginGood.delisted flag and SDS request retry logic - Add admin ImageUpload component and goods import/upload flows - Add vitest suite for website components and composables (32 tests) - Add skills, docs, plans and PRODUCT.md
This commit is contained in:
@@ -1,5 +1,42 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
font-family: 'PingFang SC';
|
||||
src: url('/fonts/pingfang-sc-thin.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'PingFang SC';
|
||||
src: url('/fonts/pingfang-sc-regular.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'PingFang SC';
|
||||
src: url('/fonts/pingfang-sc-medium.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'PingFang SC';
|
||||
src: url('/fonts/pingfang-sc-semibold.ttf') format('truetype');
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-weight: 600 900;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-inkreach-homepage-1: #f3f4f5;
|
||||
--color-inkreach-homepage-2: #f8f9fb;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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>
|
||||
<img src="/logo-unified.svg" alt="Inkreach" class="mb-4 h-[56px] w-[97px] object-contain brightness-0 invert" />
|
||||
<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">
|
||||
@@ -45,10 +45,3 @@
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (min-width: 901px) {
|
||||
.product-footer {
|
||||
margin-left: 240px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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 SHOW_EXTENDED_NAV = false;
|
||||
|
||||
const mobileMenuOpen = ref(false);
|
||||
const recommendMenuOpen = ref(false);
|
||||
@@ -60,6 +60,10 @@ function closeMobileMenu(): void {
|
||||
mobileSolutionOpen.value = false;
|
||||
}
|
||||
|
||||
function goToLogin(): void {
|
||||
window.location.href = 'https://inkpod.vip/user/login';
|
||||
}
|
||||
|
||||
function toggleMobileRecommend(): void {
|
||||
mobileRecommendOpen.value = !mobileRecommendOpen.value;
|
||||
mobileSolutionOpen.value = false;
|
||||
@@ -80,11 +84,11 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
||||
|
||||
<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>
|
||||
<NuxtLink to="/" aria-label="返回首页">
|
||||
<img src="/logo-unified.svg" alt="Inkreach" class="h-12 w-[83px] lg:h-[56px] lg:w-[97px] object-contain" />
|
||||
</NuxtLink>
|
||||
|
||||
<nav class="hidden lg:flex gap-6 text-xl flex-1 ml-8 items-center h-20">
|
||||
<nav class="desktop-nav hidden lg:flex gap-6 flex-1 ml-8 items-center h-20">
|
||||
<NuxtLink to="/product-center"
|
||||
class="transition-colors"
|
||||
:class="isProductCenter
|
||||
@@ -92,13 +96,11 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
||||
: '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'"
|
||||
class="text-gray-400 hover:text-primary hover:underline underline-offset-14 decoration-primary decoration-2 transition-colors"
|
||||
>在线设计</a>
|
||||
|
||||
<div
|
||||
v-if="SHOW_EXTENDED_NAV"
|
||||
class="relative h-full flex items-center"
|
||||
@mouseenter="openRecommendMenu"
|
||||
@mouseleave="closeRecommendMenu"
|
||||
@@ -107,7 +109,7 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
||||
class="transition-colors flex items-center gap-1"
|
||||
:class="[
|
||||
recommendMenuOpen ? 'text-primary' : '',
|
||||
isProductCenter && !recommendMenuOpen ? 'text-gray-400' : '',
|
||||
!recommendMenuOpen ? 'text-gray-400' : '',
|
||||
]"
|
||||
>
|
||||
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">选品推荐</span>
|
||||
@@ -122,6 +124,7 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="SHOW_EXTENDED_NAV"
|
||||
class="relative h-full flex items-center"
|
||||
@mouseenter="openSolutionMenu"
|
||||
@mouseleave="closeSolutionMenu"
|
||||
@@ -130,7 +133,7 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
||||
class="transition-colors flex items-center gap-1"
|
||||
:class="[
|
||||
solutionMenuOpen ? 'text-primary' : '',
|
||||
isProductCenter && !solutionMenuOpen ? 'text-gray-400' : '',
|
||||
!solutionMenuOpen ? 'text-gray-400' : '',
|
||||
]"
|
||||
>
|
||||
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">解决方案</span>
|
||||
@@ -144,13 +147,13 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="hidden lg:flex gap-6 text-xl items-center">
|
||||
<a :href="LOGIN_URL" target="_blank"
|
||||
<div class="desktop-nav hidden lg:flex gap-6 items-center">
|
||||
<button type="button" @click="goToLogin"
|
||||
class="hover:text-primary hover:underline underline-offset-12 decoration-primary decoration-2 transition-colors"
|
||||
>登录</a>
|
||||
<a :href="LOGIN_URL" target="_blank"
|
||||
>登录</button>
|
||||
<button type="button" @click="goToLogin"
|
||||
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>
|
||||
>立即开始</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -178,7 +181,9 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
||||
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" />
|
||||
<NuxtLink to="/" aria-label="返回首页" @click="closeMobileMenu">
|
||||
<img src="/logo-unified.svg" alt="Inkreach" class="h-[56px] w-[97px] object-contain" />
|
||||
</NuxtLink>
|
||||
<button @click="closeMobileMenu" class="p-2" aria-label="Close menu">
|
||||
<i class="fa-solid fa-xmark text-2xl"></i>
|
||||
</button>
|
||||
@@ -194,7 +199,8 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
||||
class="py-3 hover:text-primary transition-colors"
|
||||
>在线设计</a>
|
||||
|
||||
<div>
|
||||
<!-- Temporarily hidden until recommendation content is ready. -->
|
||||
<div v-if="SHOW_EXTENDED_NAV">
|
||||
<button
|
||||
class="w-full py-3 flex items-center justify-between hover:text-primary transition-colors"
|
||||
@click="toggleMobileRecommend"
|
||||
@@ -244,7 +250,8 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- Temporarily hidden until solution content is ready. -->
|
||||
<div v-if="SHOW_EXTENDED_NAV">
|
||||
<button
|
||||
class="w-full py-3 flex items-center justify-between hover:text-primary transition-colors"
|
||||
@click="toggleMobileSolution"
|
||||
@@ -291,12 +298,12 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col gap-4 px-6 mt-auto pb-6 pt-4">
|
||||
<a :href="LOGIN_URL" target="_blank"
|
||||
<button type="button" @click="goToLogin"
|
||||
class="py-2.5 text-lg hover:text-primary transition-colors text-center"
|
||||
>登录</a>
|
||||
<a :href="LOGIN_URL" target="_blank"
|
||||
>登录</button>
|
||||
<button type="button" @click="goToLogin"
|
||||
class="text-white rounded-lg px-6 py-3 text-center hover:shadow-lg transition-all duration-300 pulse-animation bg-primary"
|
||||
>立即开始</a>
|
||||
>立即开始</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -304,6 +311,8 @@ const isProductCenter = computed(() => route.path === '/product-center');
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.desktop-nav { font-size: 18px; }
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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')">
|
||||
<section class="company-section 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">
|
||||
@@ -48,3 +48,7 @@ const capabilities = [
|
||||
{ title: '一站式柔性交付', desc: '覆盖生产至物流, 助力品牌从概念到商业落地', icon: 'fa-solid fa-handshake' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@media (min-width: 1024px) { .company-section { height: 600px; } }
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
function goToLogin(): void {
|
||||
window.location.href = 'https://inkpod.vip/user/login';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="py-10 lg:py-16 px-4 lg:px-6 bg-primary">
|
||||
<section class="cta-section 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
|
||||
type="button"
|
||||
@click="goToLogin"
|
||||
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"
|
||||
>
|
||||
立即免费使用
|
||||
@@ -12,3 +20,7 @@
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (min-width: 1024px) { .cta-section { height: 338px; padding-top: 72px; } }
|
||||
</style>
|
||||
|
||||
@@ -1,28 +1,27 @@
|
||||
<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' },
|
||||
{ id: 1, platform: 'TEMU卖家', isPrimary: true, title: '复古水洗 经典重塑', description: '巡演灵感做旧款,粉丝自发应援首选', salesGrowth: '260%', monthlySales: '45W美元', image: '/case-temu-washed.png' },
|
||||
{ id: 2, platform: 'TikTok卖家', isPrimary: false, title: '冠军荣耀 同款套装', description: '夺冠纪念球衣,球迷珍藏爆款', salesGrowth: '300%', monthlySales: '68W美元', image: '/case-tiktok-kit.png' },
|
||||
{ id: 3, platform: 'SHEIN卖家', isPrimary: false, title: '潮流印花 全棉舒适', description: '自制设计印花图案,无尾货压力轻松售', salesGrowth: '180%', monthlySales: '22W美元', image: '/case-shein-print.png' },
|
||||
{ id: 4, platform: 'TEMU卖家', isPrimary: true, title: '天然彩棉 亲肤百搭', description: '达人联名系列,持续热卖无库存压力', salesGrowth: '210%', monthlySales: '38W美元', image: '/case-temu-cotton.png' },
|
||||
{ id: 5, platform: 'TikTok卖家', isPrimary: false, title: '日系简约 清凉无袖', description: '音乐节应援款,透气轻便人气高', salesGrowth: '240%', monthlySales: '32W美元', image: '/case-tiktok-sleeveless.png' },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="py-10 lg:py-16 bg-gray-50 overflow-hidden">
|
||||
<section class="cases-section 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 class="hidden lg:block overflow-hidden" aria-label="用户案例自动轮播">
|
||||
<div class="case-track flex gap-8 w-max">
|
||||
<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"
|
||||
class="case-card relative flex-shrink-0 w-[350px] h-[420px] bg-white rounded-xl overflow-hidden"
|
||||
>
|
||||
<div class="relative h-48">
|
||||
<div class="case-media 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']"
|
||||
>
|
||||
@@ -30,28 +29,30 @@ const customerCases = ref([
|
||||
</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>
|
||||
<div class="case-details p-6">
|
||||
<div class="case-metrics flex justify-between items-center mb-4">
|
||||
<div class="growth-metric">
|
||||
<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">
|
||||
<div class="sales-metric 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 class="case-copy">
|
||||
<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>
|
||||
|
||||
<div class="lg:hidden overflow-x-auto pb-4 px-4">
|
||||
<div class="flex gap-4 w-max">
|
||||
<div class="lg:hidden overflow-hidden pb-4 px-4" aria-label="用户案例自动轮播">
|
||||
<div class="case-track-mobile flex gap-4 w-max">
|
||||
<div
|
||||
v-for="(testCase, idx) in customerCases"
|
||||
v-for="(testCase, idx) in [...customerCases, ...customerCases]"
|
||||
:key="'m-' + idx"
|
||||
class="flex-shrink-0 w-[280px] bg-white rounded-xl overflow-hidden"
|
||||
>
|
||||
@@ -82,3 +83,43 @@ const customerCases = ref([
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.case-track { animation: case-marquee 30s linear infinite; }
|
||||
.case-track-mobile { animation: case-marquee-mobile 30s linear infinite; }
|
||||
.case-track:hover,
|
||||
.case-track:focus-within,
|
||||
.case-track-mobile:hover,
|
||||
.case-track-mobile:focus-within { animation-play-state: paused; }
|
||||
|
||||
@keyframes case-marquee {
|
||||
to { transform: translateX(calc(-50% - 16px)); }
|
||||
}
|
||||
|
||||
@keyframes case-marquee-mobile {
|
||||
to { transform: translateX(calc(-50% - 8px)); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.case-track,
|
||||
.case-track-mobile { animation: none; }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.cases-section { height: 928px; padding-top: 54px; }
|
||||
.case-track { padding-left: 122px; }
|
||||
.case-card { width: 464px; height: 500px; }
|
||||
.case-media { height: 360px; background: #f8f9fb; }
|
||||
.case-media img { object-fit: contain; }
|
||||
.case-details { position: absolute; inset: 0; padding: 0; pointer-events: none; }
|
||||
.case-metrics { display: block; margin: 0; }
|
||||
.case-metrics > div { position: absolute; right: 16px; width: 240px; text-align: right; }
|
||||
.growth-metric { top: 148px; }
|
||||
.sales-metric { top: 253px; }
|
||||
.case-metrics p:first-child { margin-bottom: 2px; font-size: 16px; }
|
||||
.case-metrics p:last-child { font-size: 48px; line-height: 1.1; white-space: nowrap; }
|
||||
.case-copy { position: absolute; top: 392px; left: 0; width: 100%; padding: 0 24px; text-align: center; }
|
||||
.case-copy h3 { margin-bottom: 12px; font-size: 24px; }
|
||||
.case-copy p { font-size: 16px; white-space: nowrap; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
const activeFeature = ref(0);
|
||||
const FEATURE_INTERVAL_MS = 2000;
|
||||
|
||||
const features = [
|
||||
{ title: '设计工具', desc: '轻松设计,快速定制', image: '/设计工具.png' },
|
||||
@@ -12,52 +15,34 @@ const selectFeature = (idx: number) => {
|
||||
activeFeature.value = idx;
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let featureTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const startAutoPlay = () => {
|
||||
stopAutoPlay();
|
||||
timer = setInterval(() => {
|
||||
onMounted(() => {
|
||||
featureTimer = setInterval(() => {
|
||||
activeFeature.value = (activeFeature.value + 1) % features.length;
|
||||
}, 3000);
|
||||
};
|
||||
}, FEATURE_INTERVAL_MS);
|
||||
});
|
||||
|
||||
const stopAutoPlay = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const pauseAutoPlay = () => {
|
||||
stopAutoPlay();
|
||||
};
|
||||
|
||||
const resumeAutoPlay = () => {
|
||||
startAutoPlay();
|
||||
};
|
||||
|
||||
onMounted(startAutoPlay);
|
||||
onUnmounted(stopAutoPlay);
|
||||
onBeforeUnmount(() => {
|
||||
if (featureTimer) clearInterval(featureTimer);
|
||||
});
|
||||
</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>
|
||||
<section class="feature-section w-full flex flex-col gap-8 lg:gap-10 items-center p-6 lg:p-10">
|
||||
<div class="text-[48px] font-extrabold 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 class="feature-list flex flex-row gap-3 overflow-x-auto lg:overflow-visible">
|
||||
<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="flex-shrink-0 flex flex-col justify-center gap-2 lg:gap-4 pl-4 lg:pl-5 w-40 lg:w-100 h-20 lg:h-auto 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="feature-item-title text-xl lg:text-3xl">{{ feat.title }}</p>
|
||||
<p class="text-sm lg:text-base indent-1">{{ feat.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,6 +53,7 @@ onUnmounted(stopAutoPlay);
|
||||
:key="'feat-img-' + idx"
|
||||
:src="feat.image"
|
||||
:alt="feat.title"
|
||||
:data-testid="`feature-image-${idx}`"
|
||||
class="w-full h-full object-contain transition-opacity duration-500 absolute inset-0"
|
||||
:class="activeFeature === idx ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
/>
|
||||
@@ -75,3 +61,17 @@ onUnmounted(stopAutoPlay);
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (min-width: 1024px) {
|
||||
.feature-section { height: 754px; padding-top: 54px; }
|
||||
.feature-section > div:nth-child(2) { height: 550px; }
|
||||
.feature-list {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(4, minmax(0, 1fr));
|
||||
width: 400px;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
function goToLogin(): void {
|
||||
window.location.href = 'https://inkpod.vip/user/login';
|
||||
}
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<section class="hero 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=""
|
||||
@@ -11,21 +17,25 @@
|
||||
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 class="hero-copy flex flex-col items-center lg:items-start gap-3 mt-4 lg:mt-0">
|
||||
<div class="hero-title text-3xl md:text-5xl font-extrabold text-center lg:text-left">
|
||||
一站定制 链<span class="hero-gradient-text">通全球</span>
|
||||
</div>
|
||||
<p class="hero-subtitle font-thin text-xl md:text-2xl text-center lg:text-left">从定制设计至生产发货如此简单</p>
|
||||
</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 class="selling-points flex items-center gap-3 text-base lg:text-xl">
|
||||
<span>0库存</span>
|
||||
<span class="selling-point-separator" aria-hidden="true">|</span>
|
||||
<span>0关税</span>
|
||||
<span class="selling-point-separator" aria-hidden="true">|</span>
|
||||
<span>1件起订</span>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
@click="goToLogin"
|
||||
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"
|
||||
>
|
||||
免费试用
|
||||
@@ -37,6 +47,33 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<img src="/主视觉.png" class="w-full max-w-5xl" />
|
||||
<img
|
||||
src="/主视觉.png"
|
||||
alt="InkReach 全球 POD 定制商品"
|
||||
width="800"
|
||||
height="400"
|
||||
class="hero-visual"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (min-width: 1024px) {
|
||||
.hero { height: auto; min-height: 0; margin-top: 80px; padding-top: 52px; padding-bottom: 32px; gap: 18px; }
|
||||
.hero-copy { align-items: flex-start; }
|
||||
.hero-title { font-size: 80px; line-height: 1.25; }
|
||||
.hero-subtitle { font-size: 48px; line-height: 1.4; }
|
||||
.hero-visual { flex: 0 0 auto; width: min(100%, 800px); height: auto; object-fit: contain; }
|
||||
}
|
||||
|
||||
.hero-visual { width: min(100%, 800px); height: auto; object-fit: contain; }
|
||||
.hero-gradient-text {
|
||||
display: inline-block;
|
||||
background: linear-gradient(90deg, #111827 0%, #111827 28%, #ff6800 100%);
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
.selling-point-separator { color: #9ca3af; }
|
||||
</style>
|
||||
|
||||
@@ -1,69 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
const {
|
||||
countryGroups,
|
||||
activeCountryIdx,
|
||||
loading,
|
||||
loadingProducts,
|
||||
countryPage,
|
||||
visibleCountries,
|
||||
canPrevCountry,
|
||||
canNextCountry,
|
||||
currentProducts,
|
||||
fetchCategories,
|
||||
selectCountry,
|
||||
} = usePodProducts();
|
||||
const { countries: configuredCountries, categories: configuredCategories, loadFilters } = useProductCenter();
|
||||
const selectedCountryId = shallowRef<string>();
|
||||
|
||||
onMounted(() => {
|
||||
fetchCategories();
|
||||
});
|
||||
async function initialize(): Promise<void> {
|
||||
await Promise.all([fetchCategories(), loadFilters()]);
|
||||
const firstCountry = configuredCountries.value[0];
|
||||
if (firstCountry) switchCountry(firstCountry);
|
||||
}
|
||||
|
||||
function switchCountry(country: { id: string; name: string }): void {
|
||||
selectedCountryId.value = country.id;
|
||||
const index = countryGroups.value.findIndex(
|
||||
(group) => group.name === country.name || group.name.includes(country.name),
|
||||
);
|
||||
if (index >= 0) selectCountry(index);
|
||||
}
|
||||
|
||||
function categoryLink(categoryName: string) {
|
||||
const categoryId = findCategoryIdForPodName(categoryName, configuredCategories.value);
|
||||
return {
|
||||
path: '/product-center',
|
||||
query: {
|
||||
countryId: selectedCountryId.value,
|
||||
...(categoryId ? { categoryId } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => void initialize());
|
||||
</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>
|
||||
<section class="pod-section w-full flex flex-col items-center p-6 lg:p-10 bg-inkreach-homepage-2">
|
||||
<div class="pod-content w-full max-w-7xl flex flex-col gap-8 lg:gap-10">
|
||||
<div class="text-[48px] font-extrabold 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">
|
||||
<div class="country-grid grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 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"
|
||||
v-for="country in configuredCountries"
|
||||
: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'"
|
||||
type="button"
|
||||
class="country-button px-4 py-2 rounded-lg transition-colors whitespace-nowrap"
|
||||
:class="selectedCountryId === country.id ? 'bg-primary text-white' : 'bg-white text-gray-600 hover:text-primary'"
|
||||
@click="switchCountry(country)"
|
||||
>
|
||||
{{ 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>
|
||||
<span class="country-flag">
|
||||
<img v-if="country.icon" :src="country.icon" :alt="country.name" />
|
||||
</span>
|
||||
<span>{{ country.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center items-center min-h-[200px]">
|
||||
<div class="product-stage 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
|
||||
<NuxtLink
|
||||
v-for="product in currentProducts"
|
||||
:key="product.id"
|
||||
:to="categoryLink(product.name)"
|
||||
class="bg-white rounded-lg overflow-hidden"
|
||||
>
|
||||
<div class="h-[14.25rem] flex items-center justify-center">
|
||||
@@ -81,20 +90,31 @@ onMounted(() => {
|
||||
<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>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-center py-6 lg:py-10">
|
||||
<div class="more-products 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"
|
||||
class="more-products-link text-primary text-base lg:text-lg font-medium hover:underline underline-offset-4"
|
||||
>
|
||||
更多产品
|
||||
<i class="fa-solid fa-arrow-right text-xs"></i>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.country-button { display: grid; grid-template-columns: 22px auto; align-items: center; justify-content: center; column-gap: 8px; min-height: 40px; }
|
||||
.country-flag { display: grid; place-items: center; width: 22px; height: 16px; }
|
||||
.country-flag img { display: block; width: 22px; height: 16px; border-radius: 2px; object-fit: cover; }
|
||||
.more-products { margin-top: auto; }
|
||||
@media (min-width: 1024px) {
|
||||
.pod-section { height: 1007px; padding-top: 54px; }
|
||||
.pod-content { height: 100%; }
|
||||
.product-stage { flex: 1; min-height: 0; align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
const activeStep = ref(0);
|
||||
const STEP_INTERVAL_MS = 2000;
|
||||
|
||||
const steps = [
|
||||
{ num: '1', title: '选择产品', desc: '海量精选产品,满足多元定制需求', image: '/01步骤-选择产品.png' },
|
||||
@@ -10,63 +13,50 @@ const steps = [
|
||||
|
||||
const selectStep = (idx: number) => {
|
||||
activeStep.value = idx;
|
||||
startStepTimer();
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let stepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const startAutoPlay = () => {
|
||||
stopAutoPlay();
|
||||
timer = setInterval(() => {
|
||||
function goToLogin(): void {
|
||||
window.location.href = 'https://inkpod.vip/user/login';
|
||||
}
|
||||
|
||||
function startStepTimer(): void {
|
||||
if (stepTimer) clearInterval(stepTimer);
|
||||
stepTimer = setInterval(() => {
|
||||
activeStep.value = (activeStep.value + 1) % steps.length;
|
||||
}, 3000);
|
||||
};
|
||||
}, STEP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
const stopAutoPlay = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
onMounted(startStepTimer);
|
||||
|
||||
const pauseAutoPlay = () => {
|
||||
stopAutoPlay();
|
||||
};
|
||||
|
||||
const resumeAutoPlay = () => {
|
||||
startAutoPlay();
|
||||
};
|
||||
|
||||
onMounted(startAutoPlay);
|
||||
onUnmounted(stopAutoPlay);
|
||||
onBeforeUnmount(() => {
|
||||
if (stepTimer) clearInterval(stepTimer);
|
||||
});
|
||||
</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>
|
||||
<section class="step-section w-full flex flex-col gap-8 lg:gap-10 items-center p-6 lg:p-10">
|
||||
<div class="text-[48px] font-extrabold 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 class="flex flex-row lg:flex-col gap-3 lg:gap-8 lg:p-5 lg:w-5/12 overflow-x-auto lg:overflow-visible justify-center">
|
||||
<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"
|
||||
@mouseenter="selectStep(idx)"
|
||||
>
|
||||
<p class="text-xl lg:text-3xl font-bold">{{ step.num }} {{ step.title }}</p>
|
||||
<p class="step-title text-xl lg:text-4xl">{{ 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 v-if="activeStep === idx" class="step-progress h-full rounded-full 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 type="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" @click="goToLogin">
|
||||
立即开始
|
||||
</button>
|
||||
</div>
|
||||
@@ -78,6 +68,7 @@ onUnmounted(stopAutoPlay);
|
||||
:key="'img-' + idx"
|
||||
:src="step.image"
|
||||
:alt="step.title"
|
||||
:data-testid="`step-image-${idx}`"
|
||||
class="w-full h-full object-contain transition-opacity duration-500 absolute inset-0"
|
||||
:class="activeStep === idx ? 'opacity-100' : 'opacity-0 pointer-events-none'"
|
||||
/>
|
||||
@@ -85,9 +76,23 @@ onUnmounted(stopAutoPlay);
|
||||
</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 type="button" class="text-xl text-white bg-primary rounded-lg px-10 py-3 hover:opacity-90 transition-opacity" @click="goToLogin">
|
||||
立即开始
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.step-progress { width: 0; animation: fill-step-progress 2s linear forwards; }
|
||||
|
||||
@keyframes fill-step-progress {
|
||||
to { width: 100%; }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.step-section { height: 935px; padding-top: 54px; }
|
||||
.step-section > div:first-child { font-size: 48px; }
|
||||
.step-section > div:nth-child(2) { height: 708px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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>
|
||||
<section class="trust-section 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-[48px] font-extrabold 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
|
||||
@@ -10,11 +10,11 @@
|
||||
>
|
||||
<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 class="trust-stat-value text-primary font-black">{{ item.value }}</div>
|
||||
<div>{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative -top-6 lg:-top-10">
|
||||
<div class="trust-illustration relative -top-6 lg:-top-10">
|
||||
<img :src="item.icon" :alt="item.label" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,3 +30,10 @@ const stats = [
|
||||
{ value: '24H', label: '急速发货', icon: '/极速发货.png' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@media (min-width: 1024px) {
|
||||
.trust-section { height: 600px; }
|
||||
.trust-illustration { margin-bottom: -40px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section class="py-10 lg:py-16 px-4 lg:px-6 bg-inkreach-homepage-2">
|
||||
<section class="why-section 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
|
||||
@@ -30,3 +30,7 @@ const reasons = [
|
||||
{ title: '全球配送', icon: '/全球配送图标.png' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@media (min-width: 1024px) { .why-section { height: 430px; } }
|
||||
</style>
|
||||
|
||||
@@ -3,146 +3,44 @@ 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)}起`;
|
||||
}
|
||||
const props = defineProps<{ product: Product; showCountry?: boolean }>();
|
||||
const visibleTags = computed(() => sortTagsByGroup(props.product.tags).slice(0, 3));
|
||||
const price = computed(() => {
|
||||
const value = Number(props.product.price);
|
||||
return Number.isFinite(value) ? value.toFixed(2) : null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="product-card">
|
||||
<a class="product-card" :href="`https://inkpod.vip/portal/detail/${product.id}`">
|
||||
<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>
|
||||
<img v-if="product.image" :src="product.image" :alt="product.name" loading="lazy" />
|
||||
<i v-else class="fa-regular fa-image" aria-hidden="true"></i>
|
||||
</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>
|
||||
<h3 :title="product.name">{{ product.name }}</h3>
|
||||
<div class="chips">
|
||||
<span v-for="tag in visibleTags" :key="tag.id" class="chip" :style="{ color: tag.fontColor || tag.color || '#21834b', backgroundColor: `${tag.color || '#eaf8ef'}30` }">{{ tag.name }}</span>
|
||||
<span v-if="showCountry" class="chip country">{{ product.country.name }}</span>
|
||||
</div>
|
||||
<div class="price">{{ formatPrice(product.price) }}</div>
|
||||
<div class="price"><template v-if="price"><small>¥</small>{{ price }}<small>起</small></template><template v-else>暂无报价</template></div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
</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;
|
||||
.product-card { display: block; width: 100%; height: 382px; overflow: hidden; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; color: inherit; text-decoration: none; transition: box-shadow .2s, transform .2s; }.product-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.08); }
|
||||
.product-media { display: grid; place-items: center; width: 100%; aspect-ratio: 1; overflow: hidden; border-radius: 8px; background: #f2f2f2; color: #bbb; font-size: 32px; }
|
||||
.product-media img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.product-info { padding: 7px 12px 9px; }
|
||||
.product-info h3 { margin: 0 0 5px; overflow: hidden; color: #000; font-size: 18px; font-weight: 500; line-height: 25px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chips { display: flex; gap: 6px; height: 21px; margin-bottom: 5px; overflow: hidden; }
|
||||
.chip { display: inline-flex; align-items: center; flex: 0 0 auto; height: 21px; padding: 0 6px; border-radius: 4px; font-size: 12px; white-space: nowrap; }
|
||||
.chip.country { color: #535353; background: #ececef; }
|
||||
.price { margin-top: 5px; color: #000; font-size: 20px; font-weight: 700; line-height: 28px; }
|
||||
.price small { font-size: 16px; font-weight: 500; }
|
||||
@media (max-width: 760px) {
|
||||
.product-card { height: auto; min-height: 0; max-width: none; overflow: visible; }
|
||||
.product-info h3 { font-size: 15px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<article class="skeleton-card">
|
||||
<div class="skeleton-media"></div>
|
||||
<div class="skeleton-media"><i class="fa-solid fa-image" aria-hidden="true"></i></div>
|
||||
<div class="skeleton-info">
|
||||
<div class="skeleton-line title"></div>
|
||||
<div class="skeleton-chips">
|
||||
@@ -31,10 +31,14 @@
|
||||
}
|
||||
|
||||
.skeleton-media {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.skeleton-media i { position: relative; z-index: 1; color: #c2c2c2; font-size: 44px; }
|
||||
|
||||
.skeleton-media::after,
|
||||
.skeleton-line::after,
|
||||
.skeleton-chip::after {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
const portalUrl = 'https://inkpod.vip/portal/search';
|
||||
const loginUrl = 'https://inkpod.vip/user/login';
|
||||
</script>
|
||||
<template>
|
||||
<header class="header">
|
||||
<NuxtLink to="/" class="logo"><img src="/logo-unified.svg" alt="InkReach" /></NuxtLink>
|
||||
<nav><NuxtLink to="/product-center" class="active">产品中心</NuxtLink><a :href="portalUrl" target="_blank">在线设计</a><a href="#">选品推荐</a><a href="#">解决方案</a></nav>
|
||||
<div class="actions"><a :href="loginUrl" target="_blank"><i class="fa-solid fa-desktop"></i>工作台</a><a :href="loginUrl" target="_blank"><i class="fa-solid fa-cart-shopping"></i>购物车</a><a class="phone" href="tel:18588980344"><i class="fa-solid fa-circle-user"></i>18588980344<i class="fa-solid fa-chevron-down"></i></a></div>
|
||||
</header>
|
||||
</template>
|
||||
<style scoped>
|
||||
.header { position: fixed; z-index: 60; top: 0; left: 0; right: 0; display: flex; align-items: center; height: 80px; padding: 0 70px; border-bottom: 1px solid #e5e7eb; background: #fff; }
|
||||
.logo { width: 97px; height: 56px; display: grid; place-items: center; }.logo img { width: 97px; height: 56px; object-fit: contain; }
|
||||
nav { display: flex; gap: 32px; margin-left: 44px; } nav a { color: rgba(0,0,0,.5); font-size: 18px; text-decoration: none; white-space: nowrap; } nav .active { color: #000; }
|
||||
.actions { display: flex; align-items: center; gap: 24px; margin-left: auto; }.actions a { display: flex; align-items: center; gap: 8px; color: rgba(0,0,0,.62); font-size: 16px; text-decoration: none; white-space: nowrap; }.actions i { font-size: 18px; }.actions .phone { color: #222; font-size: 18px; }.phone .fa-chevron-down { margin-left: 4px; font-size: 10px; }
|
||||
@media (max-width: 1000px) { .header { padding: 0 20px; }.header nav { display: none; }.actions a:not(.phone) { display: none; } }
|
||||
@media (max-width: 520px) { .actions .phone { font-size: 0; }.actions .phone i { font-size: 20px; } }
|
||||
</style>
|
||||
@@ -1,89 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Country } from '~/composables/useProductCenter';
|
||||
|
||||
defineProps<{
|
||||
countries: Country[];
|
||||
activeCountryId: string | null;
|
||||
}>();
|
||||
const props = defineProps<{ countries: Country[]; activeCountryId: string | null }>();
|
||||
const emit = defineEmits<{ 'update:activeCountryId': [id: string | null] }>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:activeCountryId': [id: string | null];
|
||||
}>();
|
||||
|
||||
function selectCountry(id: string | null): void {
|
||||
emit('update:activeCountryId', id);
|
||||
}
|
||||
const configuredCountries = computed(() => props.countries);
|
||||
</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 class="country-bar" aria-label="国家筛选">
|
||||
<button type="button" class="filter-pill" :class="{ active: activeCountryId === null }" @click="emit('update:activeCountryId', null)">全部</button>
|
||||
<template v-for="country in configuredCountries" :key="country.id">
|
||||
<button type="button" class="filter-pill" :class="{ active: activeCountryId === country.id }" @click="emit('update:activeCountryId', country.id)">
|
||||
<img v-if="country.icon" :src="country.icon" :alt="country.name" class="flag" />
|
||||
{{ country.name }}
|
||||
</button>
|
||||
</template>
|
||||
</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;
|
||||
}
|
||||
.country-bar { display: flex; align-items: center; gap: 12px; width: 100%; min-width: 0; max-width: 100%; flex-wrap: wrap; }
|
||||
.filter-pill { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 38px; padding: 0 16px; border: 0; border-radius: 60px; background: #f2f2f2; color: #000; font-size: 16px; white-space: nowrap; cursor: pointer; }
|
||||
.filter-pill.active { color: #ff6800; background: rgba(255, 104, 0, .06); }
|
||||
.flag { width: 22px; height: 16px; border-radius: 2px; object-fit: cover; }
|
||||
@media (max-width: 760px) { .country-bar { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; } }
|
||||
</style>
|
||||
|
||||
@@ -1,108 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, watch } from 'vue';
|
||||
|
||||
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');
|
||||
}
|
||||
const emit = defineEmits<{ search: [] }>();
|
||||
const localKeyword = shallowRef(keyword.value);
|
||||
watch(keyword, (value) => { localKeyword.value = value; });
|
||||
function submit(): void { keyword.value = localKeyword.value.trim(); emit('search'); }
|
||||
function clear(): 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>
|
||||
<form class="search-box" role="search" @submit.prevent="submit">
|
||||
<i class="fa-solid fa-magnifying-glass search-icon" aria-hidden="true"></i>
|
||||
<input v-model="localKeyword" aria-label="搜索商品" placeholder="搜索商品" />
|
||||
<button v-if="localKeyword" class="clear-search" type="button" aria-label="清除搜索" @click="clear"><i class="fa-solid fa-xmark"></i></button>
|
||||
<button class="submit-search" type="submit">搜索</button>
|
||||
</form>
|
||||
</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;
|
||||
}
|
||||
.search-box { position: relative; display: flex; align-items: center; width: 220px; height: 38px; border: 1px solid #c7c7c7; border-radius: 60px; background: #fff; overflow: hidden; }
|
||||
.search-icon { position: absolute; left: 12px; color: #666; font-size: 14px; }
|
||||
.search-box input { min-width: 0; flex: 1; height: 100%; padding: 0 2px 0 34px; border: 0; outline: 0; background: transparent; font-size: 16px; }
|
||||
.search-box button { flex: 0 0 auto; height: 34px; padding: 0; border: 0; background: transparent; cursor: pointer; }
|
||||
.search-box .clear-search { width: 28px; color: #777; font-size: 14px; }.search-box .submit-search { width: 50px; color: #ff6800; font-size: 16px; }
|
||||
@media (max-width: 760px) { .search-box { width: 100%; } }
|
||||
</style>
|
||||
|
||||
@@ -1,127 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { Product } from '~/composables/useProductCenter';
|
||||
|
||||
const props = defineProps<{
|
||||
products: Product[];
|
||||
loading: boolean;
|
||||
showCountry?: boolean;
|
||||
}>();
|
||||
|
||||
const skeletonCount = 12;
|
||||
defineProps<{ products: Product[]; loading: boolean; showCountry?: boolean }>();
|
||||
const emit = defineEmits<{ 'clear-filters': []; 'view-all': [] }>();
|
||||
</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 v-if="loading" class="product-grid"><ProductCardSkeleton v-for="index in 8" :key="index" /></div>
|
||||
<div v-else-if="!products.length" class="empty">
|
||||
<img src="/product-empty.svg" alt="" />
|
||||
<h2>未找到相关商品</h2>
|
||||
<p>当前筛选条件下暂无匹配产品,可尝试调整关键词或减少筛选条件。</p>
|
||||
<div class="empty-actions"><button type="button" class="view-all" @click="emit('view-all')">查看全部商品</button><button type="button" class="clear" @click="emit('clear-filters')">清空筛选</button></div>
|
||||
</div>
|
||||
<div v-else class="product-grid"><ProductCard v-for="product in products" :key="product.id" :product="product" :show-country="showCountry" /></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;
|
||||
}
|
||||
}
|
||||
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px 24px; }
|
||||
.empty { display: flex; flex-direction: column; align-items: center; min-height: 600px; padding-top: 86px; color: rgba(0,0,0,.6); text-align: center; }.empty img { width: 260px; height: 260px; object-fit: contain; }.empty h2 { margin: 12px 0 10px; color: #000; font-size: 22px; font-weight: 600; }.empty p { margin: 0; font-size: 14px; }.empty-actions { display: flex; gap: 24px; margin-top: 34px; }.empty-actions button { height: 38px; padding: 0 16px; border: 0; border-radius: 60px; font-size: 16px; cursor: pointer; }.empty-actions .view-all { color: #ff6800; background: rgba(255,104,0,.06); }.empty-actions .clear { min-width: 128px; color: #fff; background: #ff6800; }
|
||||
@media (max-width: 1180px) { .product-grid { gap: 16px; } }
|
||||
@media (max-width: 760px) { .product-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 430px) { .product-grid { grid-template-columns: minmax(0, 1fr); } }
|
||||
</style>
|
||||
|
||||
@@ -1,249 +1,25 @@
|
||||
<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();
|
||||
}
|
||||
}
|
||||
const props = defineProps<{ total: number; page: number; pageSize: number }>();
|
||||
const emit = defineEmits<{ 'update:page': [page: number]; 'update:pageSize': [size: number] }>();
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)));
|
||||
const pages = computed<(number | string)[]>(() => totalPages.value <= 7 ? Array.from({ length: totalPages.value }, (_, i) => i + 1) : [1, 2, 3, 4, 5, '•••', totalPages.value]);
|
||||
const goto = shallowRef('');
|
||||
function go(page: number): void { if (page >= 1 && page <= totalPages.value && page !== props.page) emit('update:page', page); }
|
||||
function jump(): void { go(Math.floor(Number(goto.value))); goto.value = ''; }
|
||||
</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>
|
||||
<strong>总计{{ total }}个产品</strong>
|
||||
<button type="button" :disabled="page <= 1" aria-label="上一页" @click="go(page - 1)"><i class="fa-solid fa-chevron-left"></i></button>
|
||||
<template v-for="item in pages" :key="item"><span v-if="typeof item === 'string'" class="dots">{{ item }}</span><button v-else type="button" :class="{ current: item === page }" @click="go(item)">{{ item }}</button></template>
|
||||
<button type="button" :disabled="page >= totalPages" aria-label="下一页" @click="go(page + 1)"><i class="fa-solid fa-chevron-right"></i></button>
|
||||
<select :value="pageSize" aria-label="每页数量" @change="emit('update:pageSize', Number(($event.target as HTMLSelectElement).value))"><option :value="10">10 / 每页</option><option :value="20">20 / 每页</option><option :value="40">40 / 每页</option></select>
|
||||
<label>导航至<input v-model="goto" type="number" min="1" :max="totalPages" @keydown.enter.prevent="jump" /></label>
|
||||
</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;
|
||||
}
|
||||
.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 32px 0; color: rgba(0,0,0,.85); font-size: 14px; }.pagination strong { margin-right: 2px; font-size: 13px; }
|
||||
button { display: grid; place-items: center; min-width: 35px; height: 32px; border: 1px solid #d9d9d9; border-radius: 4px; background: #fff; cursor: pointer; }button.current { color: #ff6800; border-color: #ff6800; }button:disabled { opacity: .4; }.dots { width: 35px; text-align: center; color: rgba(0,0,0,.3); letter-spacing: 2px; }
|
||||
select,input { height: 32px; border: 1px solid #d9d9d9; border-radius: 4px; background: #fff; }select { width: 112px; padding: 0 12px; }label { display: flex; align-items: center; gap: 8px; }input { width: 55px; padding: 0 8px; }
|
||||
@media (max-width: 760px) { .pagination { flex-wrap: wrap; }.pagination strong { width: 100%; text-align: center; } }
|
||||
</style>
|
||||
|
||||
@@ -1,257 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
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];
|
||||
const props = defineProps<{ categories: Category[]; activeCategoryId: string | null }>();
|
||||
const emit = defineEmits<{ 'update:activeCategoryId': [id: string | null] }>();
|
||||
const expanded = reactive<Record<string, boolean>>({});
|
||||
function selectRootCategory(category: Category): void {
|
||||
emit('update:activeCategoryId', category.id);
|
||||
if (category.children.length) expanded[category.id] = !expanded[category.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 },
|
||||
);
|
||||
watch(() => [props.categories, props.activeCategoryId] as const, () => {
|
||||
for (const category of props.categories) {
|
||||
if (category.id === props.activeCategoryId || category.children.some((item) => item.id === props.activeCategoryId)) expanded[category.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>
|
||||
<nav class="sidebar" aria-label="商品分类">
|
||||
<button class="root-row" :class="{ active: activeCategoryId === null }" type="button" @click="emit('update:activeCategoryId', null)"><i class="fa-solid fa-border-all"></i><span>全部商品</span></button>
|
||||
<template v-for="category in categories" :key="category.id">
|
||||
<button class="root-row" :class="{ active: activeCategoryId === category.id }" type="button" @click="selectRootCategory(category)">
|
||||
<img v-if="category.icon" :src="category.icon" alt="" /><i v-else class="fa-regular fa-folder"></i><span>{{ category.name }}</span><i v-if="category.children.length" class="fa-solid fa-chevron-down arrow" :class="{ open: expanded[category.id] }"></i>
|
||||
</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>
|
||||
<div v-if="category.children.length && expanded[category.id]" class="children">
|
||||
<button v-for="child in category.children" :key="child.id" type="button" :class="{ active: activeCategoryId === child.id }" @click="emit('update:activeCategoryId', child.id)">{{ child.name }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-if="!categories.length" class="empty">暂无分类</p>
|
||||
</nav>
|
||||
</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;
|
||||
}
|
||||
.sidebar { width: 216px; padding-top: 12px; }
|
||||
.root-row { display: flex; align-items: center; width: 216px; height: 45px; margin-bottom: 10px; padding: 10px; border: 0; border-radius: 6px; background: #fff; color: #000; font-size: 18px; cursor: pointer; }
|
||||
.root-row:hover, .root-row.active, .children button.active { color: #ff6800; background: #fff3eb; }.root-row img { filter: brightness(0); }.root-row.active img { filter: invert(48%) sepia(99%) saturate(3770%) hue-rotate(3deg) brightness(104%) contrast(104%); }
|
||||
.root-row img, .root-row > i:first-child { width: 24px; height: 24px; margin-right: 8px; object-fit: contain; text-align: center; line-height: 24px; }
|
||||
.root-row span { flex: 1; text-align: left; }
|
||||
.arrow { font-size: 12px; transition: transform .2s; }.arrow.open { transform: rotate(180deg); }
|
||||
.children { position: relative; margin-left: 10px; padding-left: 20px; border-left: 1px solid #e5e7eb; }
|
||||
.children button { position: relative; display: block; width: 174px; height: 45px; margin-bottom: 10px; padding: 10px; border: 0; border-radius: 6px; background: #fff; color: #000; text-align: left; font-size: 18px; cursor: pointer; }
|
||||
.children button::before { content: ''; position: absolute; top: 22px; left: -14px; width: 6px; border-top: 1px solid #e5e7eb; }
|
||||
.empty { color: #777; text-align: center; }
|
||||
</style>
|
||||
|
||||
@@ -1,191 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import type { Tag } from '~/composables/useProductCenter';
|
||||
import { computed, onBeforeUnmount, onMounted, shallowRef } from 'vue';
|
||||
import type { Tag, TagGroup } from '~/composables/useProductCenter';
|
||||
|
||||
const props = defineProps<{
|
||||
tags: Tag[];
|
||||
selectedTagIds: string[];
|
||||
}>();
|
||||
const props = defineProps<{ tags: Tag[]; tagGroups: TagGroup[]; selectedTagIds: string[] }>();
|
||||
const emit = defineEmits<{ 'toggle-tag': [id: string] }>();
|
||||
const openGroup = shallowRef<string | null>(null);
|
||||
const rootElement = shallowRef<HTMLElement | null>(null);
|
||||
const groupedTags = computed(() => {
|
||||
return props.tagGroups
|
||||
.filter((group) => group.name.includes('物流') || group.name.includes('工艺'))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.name.includes('物流') ? '物流' : '工艺',
|
||||
tags: props.tags.filter((tag) => tag.group?.id === group.id),
|
||||
}))
|
||||
.filter((group) => group.tags.length > 0);
|
||||
});
|
||||
const selectedTagByGroup = computed(() => {
|
||||
const selectedIds = new Set(props.selectedTagIds);
|
||||
return new Map(
|
||||
props.tags
|
||||
.filter((tag) => selectedIds.has(tag.id) && tag.group)
|
||||
.map((tag) => [tag.group!.id, tag]),
|
||||
);
|
||||
});
|
||||
const selectedGroups = computed(() => groupedTags.value
|
||||
.map((group) => ({ ...group, selectedTag: selectedTagByGroup.value.get(group.id) }))
|
||||
.filter((group) => group.selectedTag));
|
||||
|
||||
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 {
|
||||
function selectTag(id: string): void {
|
||||
emit('toggle-tag', id);
|
||||
openGroup.value = null;
|
||||
}
|
||||
|
||||
function isSelected(id: string): boolean {
|
||||
return props.selectedTagIds.includes(id);
|
||||
function closeOnOutsideClick(event: MouseEvent): void {
|
||||
if (!rootElement.value?.contains(event.target as Node)) openGroup.value = null;
|
||||
}
|
||||
|
||||
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})`;
|
||||
});
|
||||
onMounted(() => document.addEventListener('click', closeOnOutsideClick));
|
||||
onBeforeUnmount(() => document.removeEventListener('click', closeOnOutsideClick));
|
||||
</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>
|
||||
<div ref="rootElement" class="tag-filters">
|
||||
<div class="filter-controls">
|
||||
<div v-for="group in groupedTags" :key="group.id" class="filter-group">
|
||||
<div class="dropdown">
|
||||
<button type="button" class="filter-button" :class="{ open: openGroup === group.id }" @click="openGroup = openGroup === group.id ? null : group.id">{{ group.label }}<i class="fa-solid fa-chevron-down"></i></button>
|
||||
<div v-if="openGroup === group.id" class="menu">
|
||||
<button v-for="tag in group.tags" :key="tag.id" type="button" :class="{ selected: selectedTagIds.includes(tag.id) }" @click="selectTag(tag.id)"><span>{{ tag.name }}</span><span class="checkbox"><i v-if="selectedTagIds.includes(tag.id)" class="fa-solid fa-check"></i></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedGroups.length" class="selected-tags" :class="{ 'craft-only': selectedGroups.length === 1 && selectedGroups[0]?.label === '工艺' }">
|
||||
<div v-for="group in selectedGroups" :key="group.id" class="selected-tag">
|
||||
<span>{{ group.selectedTag?.name }}</span>
|
||||
<button type="button" class="selected-tag-remove" :aria-label="`取消${group.selectedTag?.name}筛选`" @click="emit('toggle-tag', group.selectedTag!.id)">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</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);
|
||||
}
|
||||
.tag-filters { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; }.filter-controls, .selected-tags { display: flex; align-items: flex-start; gap: 12px; }.filter-group { display: flex; align-items: flex-start; }.dropdown { position: relative; }
|
||||
.filter-button { display: flex; align-items: center; gap: 6px; height: 38px; padding: 0 16px; border: 1px solid #c7c7c7; border-radius: 60px; background: #fff; color: #000; font-size: 16px; cursor: pointer; }.filter-button i { color: #777; font-size: 11px; transition: transform .2s; }.filter-button.open i { transform: rotate(180deg); }
|
||||
.menu { position: absolute; z-index: 40; top: 50px; left: -2px; width: 141px; padding: 6px; border-radius: 6px; background: #fff; box-shadow: 0 1px 5px rgba(0,0,0,.25); }
|
||||
.menu button { display: flex; align-items: center; justify-content: space-between; width: 129px; height: 36px; padding: 6px; border: 0; background: #fff; color: #000; text-align: left; font-size: 16px; cursor: pointer; }.menu button:hover { background: #fafafa; }
|
||||
.checkbox { display: grid; place-items: center; width: 18px; height: 18px; border: 1px solid #c7c7c7; border-radius: 4px; color: #fff; font-size: 11px; }.menu button.selected .checkbox { border-color: #ff6800; background: #ff6800; }
|
||||
.selected-tag { display: flex; align-items: center; justify-content: center; gap: 6px; height: 30px; padding: 0 16px; border-radius: 60px; background: #f2f2f2; color: #000; font-size: 16px; line-height: normal; white-space: nowrap; }.selected-tag-remove { display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; background: transparent; color: #000; font-size: 16px; cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -44,10 +44,10 @@ const COUNTRY_NAME_MAP: Record<string, string> = {
|
||||
'墨西哥工厂本地直发': '墨西哥',
|
||||
'巴西本地工厂直发': '巴西',
|
||||
'中东本地工厂直发': '中东',
|
||||
'欧洲波兰工厂直发': '欧洲波兰',
|
||||
'欧洲西班牙工厂直发': '欧洲西班牙',
|
||||
'欧洲德国工厂本地直发': '欧洲德国',
|
||||
'欧洲意大利工厂直发': '欧洲意大利',
|
||||
'欧洲波兰工厂直发': '波兰',
|
||||
'欧洲西班牙工厂直发': '西班牙',
|
||||
'欧洲德国工厂本地直发': '德国',
|
||||
'欧洲意大利工厂直发': '意大利',
|
||||
'英国本地直发': '英国',
|
||||
'加拿大本地工厂直发': '加拿大',
|
||||
'澳大利亚本地工厂直发': '澳大利亚',
|
||||
|
||||
@@ -56,6 +56,64 @@ export interface ProductQuery {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export function resolveBackendAssetUrl(value: string | null, backendUrl: string): string | null {
|
||||
if (!value || /^(?:https?:|data:|blob:)/i.test(value)) return value;
|
||||
return new URL(value, `${backendUrl.replace(/\/$/, '')}/`).toString();
|
||||
}
|
||||
|
||||
export function resolveCountryIdFromQuery(
|
||||
value: string | string[] | null | undefined,
|
||||
countries: Country[],
|
||||
): string | undefined {
|
||||
const countryId = Array.isArray(value) ? value[0] : value;
|
||||
return countryId && countries.some((country) => country.id === countryId) ? countryId : undefined;
|
||||
}
|
||||
|
||||
export function resolveCategoryIdFromQuery(
|
||||
value: string | string[] | null | undefined,
|
||||
categories: Category[],
|
||||
): string | undefined {
|
||||
const categoryId = Array.isArray(value) ? value[0] : value;
|
||||
const containsId = (nodes: Category[]): boolean => nodes.some(
|
||||
(category) => category.id === categoryId || containsId(category.children),
|
||||
);
|
||||
return categoryId && containsId(categories) ? categoryId : undefined;
|
||||
}
|
||||
|
||||
export function findCategoryIdByName(name: string, categories: Category[]): string | undefined {
|
||||
for (const category of categories) {
|
||||
if (category.name === name) return category.id;
|
||||
const childId = findCategoryIdByName(name, category.children);
|
||||
if (childId) return childId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findCategoryIdForPodName(name: string, categories: Category[]): string | undefined {
|
||||
const rootName = name.includes('男士')
|
||||
? '男士服装'
|
||||
: name.includes('女士')
|
||||
? '女士服装'
|
||||
: /童装|儿童/.test(name)
|
||||
? '儿童服装'
|
||||
: undefined;
|
||||
const root = rootName ? categories.find((category) => category.name === rootName) : undefined;
|
||||
if (!root) return findCategoryIdByName(name, categories);
|
||||
|
||||
const childName = name.includes('T恤')
|
||||
? 'T恤'
|
||||
: name.includes('裤')
|
||||
? '裤装'
|
||||
: name.includes('卫衣')
|
||||
? '卫衣'
|
||||
: name.includes('裙')
|
||||
? '裙装'
|
||||
: name.includes('内衣')
|
||||
? '内衣'
|
||||
: undefined;
|
||||
return root.children.find((category) => category.name === childName)?.id ?? root.id;
|
||||
}
|
||||
|
||||
interface BackendCategoryNode {
|
||||
id: string;
|
||||
categoryName: string;
|
||||
@@ -111,21 +169,21 @@ interface BackendEnvelope<T> {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
function mapCategory(node: BackendCategoryNode): Category {
|
||||
function mapCategory(node: BackendCategoryNode, backendUrl: string): Category {
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.categoryName,
|
||||
icon: node.categoryIcon,
|
||||
icon: resolveBackendAssetUrl(node.categoryIcon, backendUrl),
|
||||
parentId: node.parentCategoryId,
|
||||
children: node.children.map(mapCategory),
|
||||
children: node.children.map((child) => mapCategory(child, backendUrl)),
|
||||
};
|
||||
}
|
||||
|
||||
function mapCountry(c: BackendCountry): Country {
|
||||
function mapCountry(c: BackendCountry, backendUrl: string): Country {
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.countryName,
|
||||
icon: c.countryIcon,
|
||||
icon: resolveBackendAssetUrl(c.countryIcon, backendUrl),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,18 +218,41 @@ export function sortTagsByGroup(tags: Tag[]): Tag[] {
|
||||
});
|
||||
}
|
||||
|
||||
function mapProduct(p: BackendProduct): Product {
|
||||
export function selectExclusiveTagIds(selectedIds: string[], tags: Tag[], tagId: string): string[] {
|
||||
const selectedTag = tags.find((tag) => tag.id === tagId);
|
||||
if (!selectedTag) return selectedIds;
|
||||
if (selectedIds.includes(tagId)) return selectedIds.filter((id) => id !== tagId);
|
||||
|
||||
const selectedGroupId = selectedTag.group?.id;
|
||||
const otherGroups = selectedIds.filter((id) => {
|
||||
const tag = tags.find((item) => item.id === id);
|
||||
return !selectedGroupId || tag?.group?.id !== selectedGroupId;
|
||||
});
|
||||
return [...otherGroups, tagId];
|
||||
}
|
||||
|
||||
export function resolveDefaultProductFilters(
|
||||
_categories: Category[],
|
||||
_tags: Tag[],
|
||||
): Pick<ProductQuery, 'categoryId' | 'tagIds'> {
|
||||
return {
|
||||
categoryId: undefined,
|
||||
tagIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function mapProduct(p: BackendProduct, backendUrl: string): Product {
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.goodName,
|
||||
priority: p.goodPriority,
|
||||
image: p.image,
|
||||
image: resolveBackendAssetUrl(p.image, backendUrl),
|
||||
price: p.price,
|
||||
country: mapCountry(p.country),
|
||||
country: mapCountry(p.country, backendUrl),
|
||||
category: {
|
||||
id: p.category.id,
|
||||
name: p.category.categoryName,
|
||||
icon: p.category.categoryIcon,
|
||||
icon: resolveBackendAssetUrl(p.category.categoryIcon, backendUrl),
|
||||
parentId: null,
|
||||
children: [],
|
||||
},
|
||||
@@ -181,6 +262,7 @@ function mapProduct(p: BackendProduct): Product {
|
||||
}
|
||||
|
||||
export function useProductCenter() {
|
||||
const backendUrl = useRuntimeConfig().public.backendUrl;
|
||||
const categories = useState<Category[]>('pc-categories', () => []);
|
||||
const countries = useState<Country[]>('pc-countries', () => []);
|
||||
const tags = useState<Tag[]>('pc-tags', () => []);
|
||||
@@ -216,15 +298,15 @@ export function useProductCenter() {
|
||||
$fetch<BackendEnvelope<BackendCategoryNode[]>>('/api/backend/categories'),
|
||||
$fetch<BackendEnvelope<BackendCountry[]>>('/api/backend/countries'),
|
||||
]);
|
||||
categories.value = cats.data.map(mapCategory);
|
||||
countries.value = cos.data.map(mapCountry);
|
||||
categories.value = cats.data.map((category) => mapCategory(category, backendUrl));
|
||||
countries.value = cos.data.map((country) => mapCountry(country, backendUrl));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load filters:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProducts(): Promise<void> {
|
||||
async function fetchProducts(options: { ignoreTags?: boolean } = {}): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: Record<string, string | number> = {
|
||||
@@ -233,14 +315,16 @@ export function useProductCenter() {
|
||||
};
|
||||
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 (!options.ignoreTags && query.tagIds.length > 0) {
|
||||
params.tagIds = query.tagIds.join(',');
|
||||
}
|
||||
if (query.keyword?.trim()) params.keyword = query.keyword.trim();
|
||||
|
||||
const response = await $fetch<BackendEnvelope<BackendPaginatedProducts>>(
|
||||
'/api/backend/goods',
|
||||
{ params },
|
||||
);
|
||||
products.value = response.data.items.map(mapProduct);
|
||||
products.value = response.data.items.map((product) => mapProduct(product, backendUrl));
|
||||
total.value = response.data.total;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch products:', error);
|
||||
@@ -262,12 +346,7 @@ export function useProductCenter() {
|
||||
}
|
||||
|
||||
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.tagIds = selectExclusiveTagIds(query.tagIds, tags.value, id);
|
||||
query.page = 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,387 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import type { Tag, Country } from '~/composables/useProductCenter';
|
||||
import { sortTagsByGroup } from '~/composables/useProductCenter';
|
||||
import { resolveCategoryIdFromQuery, resolveCountryIdFromQuery, resolveDefaultProductFilters } from '~/composables/useProductCenter';
|
||||
|
||||
useSeoMeta({
|
||||
title: '产品中心 | InkReach',
|
||||
description:
|
||||
'InkReach 产品中心:按品类和国家筛选全球 POD 定制商品,支持关键词搜索、分页浏览与多维筛选。',
|
||||
});
|
||||
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('');
|
||||
const { categories, countries, tags, tagGroups, products, total, loading, query, loadFilters, fetchProducts, setCategory, setCountry, setTag, clearTags, setKeyword, setPage, setPageSize } = useProductCenter();
|
||||
const keyword = shallowRef('');
|
||||
const mobileSidebarOpen = shallowRef(false);
|
||||
const route = useRoute();
|
||||
|
||||
await loadFilters();
|
||||
const defaults = resolveDefaultProductFilters(categories.value, tags.value);
|
||||
query.categoryId = resolveCategoryIdFromQuery(route.query.categoryId, categories.value) ?? defaults.categoryId;
|
||||
query.tagIds = defaults.tagIds;
|
||||
query.countryId = resolveCountryIdFromQuery(route.query.countryId, countries.value);
|
||||
query.pageSize = 20;
|
||||
await fetchProducts();
|
||||
|
||||
watch(
|
||||
() => [
|
||||
query.countryId,
|
||||
query.categoryId,
|
||||
[...query.tagIds],
|
||||
query.keyword,
|
||||
query.page,
|
||||
query.pageSize,
|
||||
],
|
||||
() => {
|
||||
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);
|
||||
});
|
||||
function chooseCategory(id: string | null): void { setCategory(id); mobileSidebarOpen.value = false; }
|
||||
function search(): void { setKeyword(keyword.value); }
|
||||
function clearAll(): void { setCategory(null); setCountry(null); clearTags(); keyword.value = ''; setKeyword(''); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="product-page">
|
||||
<div class="page">
|
||||
<AppHeader />
|
||||
<button class="mobile-filter-trigger" type="button" @click="mobileSidebarOpen = true"><i class="fa-solid fa-sliders"></i>商品分类</button>
|
||||
<aside class="sidebar-shell" :class="{ open: mobileSidebarOpen }">
|
||||
<button class="drawer-close" type="button" aria-label="关闭分类" @click="mobileSidebarOpen = false"><i class="fa-solid fa-xmark"></i></button>
|
||||
<ProductSidebar :categories="categories" :active-category-id="query.categoryId ?? null" @update:active-category-id="chooseCategory" />
|
||||
<!-- <img class="promo" src="/product-promo-banner.png" alt="印美达全球 POD 供应链" /> -->
|
||||
</aside>
|
||||
<button v-if="mobileSidebarOpen" class="backdrop" type="button" aria-label="关闭分类" @click="mobileSidebarOpen = false"></button>
|
||||
|
||||
<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>
|
||||
<main class="main">
|
||||
<section class="filters">
|
||||
<div class="countries"><ProductCountryFilter :countries="countries" :active-country-id="query.countryId ?? null" @update:active-country-id="setCountry" /></div>
|
||||
<div class="filter-tools">
|
||||
<ProductTagFilter :tags="tags" :tag-groups="tagGroups" :selected-tag-ids="query.tagIds" @toggle-tag="setTag" />
|
||||
<ProductFilterBar v-model:keyword="keyword" @search="search" />
|
||||
</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>
|
||||
|
||||
<section class="catalog"><ProductGrid :products="products" :loading="loading" :show-country="!query.countryId" @clear-filters="clearAll" @view-all="clearAll" /></section>
|
||||
<ProductPagination v-if="!loading && total" :total="total" :page="query.page" :page-size="query.pageSize" @update:page="setPage" @update:page-size="setPageSize" />
|
||||
</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%;
|
||||
}
|
||||
.page { --page-gutter: clamp(24px, 3vw, 56px); min-height: 100vh; padding-top: 80px; background: #fff; color: #000; }
|
||||
.sidebar-shell { position: fixed; z-index: 30; top: 80px; bottom: 0; left: var(--page-gutter); width: 240px; padding: 0 12px; border-right: 1px solid #e5e7eb; background: #fff; overflow-y: auto; scrollbar-width: none; }
|
||||
.sidebar-shell::-webkit-scrollbar { display: none; }
|
||||
/* Banner is temporarily hidden because expanded category trees can overlap it. */
|
||||
.promo { position: absolute; top: 752px; left: 12px; width: 216px; height: 60px; border-radius: 28px; object-fit: cover; }
|
||||
.main { min-width: 0; margin-right: var(--page-gutter); margin-left: calc(240px + var(--page-gutter)); }.filters { position: sticky; z-index: 20; top: 80px; min-width: 0; background: #fff; }.countries { position: relative; min-width: 0; max-width: 100%; padding: 12px; overflow: visible; }.filter-tools { position: relative; display: flex; justify-content: space-between; min-height: 60px; padding: 12px; }
|
||||
.countries::after, .filter-tools::after { position: absolute; right: calc(-1 * var(--page-gutter)); bottom: 0; left: 0; height: 1px; background: #e5e7eb; content: ''; pointer-events: none; }
|
||||
.catalog { padding: 24px; }.drawer-close,.mobile-filter-trigger,.backdrop { display: none; }
|
||||
@media (min-width: 1440px) { .catalog { padding-left: 24px; }.countries { padding-right: 12px; } }
|
||||
@media (max-width: 1000px) {
|
||||
.mobile-filter-trigger { position: fixed; z-index: 25; top: 92px; left: 16px; display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; border: 1px solid #c7c7c7; border-radius: 60px; background: #fff; }
|
||||
.sidebar-shell { z-index: 80; top: 0; left: 0; width: 240px; transform: translateX(-100%); transition: transform .2s; }.sidebar-shell.open { transform: translateX(0); }.drawer-close { display: grid; place-items: center; width: 36px; height: 36px; margin: 10px 0 0 auto; border: 0; background: transparent; font-size: 20px; }.backdrop { position: fixed; z-index: 70; inset: 0; display: block; border: 0; background: rgba(0,0,0,.36); }
|
||||
.main { margin-right: 0; margin-left: 0; }.filters { position: relative; top: 0; }.countries { padding-top: 62px; }.filter-tools { gap: 12px; flex-wrap: wrap; }.countries::after, .filter-tools::after { right: 0; }
|
||||
}
|
||||
@media (max-width: 760px) { .countries { min-height: auto; }.catalog { padding: 16px; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user