feat(deploy): production deployment setup and fixes

- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma
  binaryTargets for openssl 3.0
- nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal
  cron, http->https redirect
- prisma: add origin_goods.delisted migration, sync missing schema
  (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz
- api: CORS wildcard reflection, helmet CORP cross-origin, price
  backfill in persistProductDetail, categoryIcon ancestor fallback,
  mediaByColor per-color gallery in public goods detail
- admin: /admin base path (vite + router)
- import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
yeuimu
2026-08-26 14:23:09 +08:00
parent be0b90e68f
commit 6c61a4e871
982 changed files with 74156 additions and 179393 deletions
+47 -47
View File
@@ -1,47 +1,47 @@
<template>
<footer class="product-footer text-white py-10 lg:py-16 px-4 lg:px-6 bg-inkreach-homepage-5">
<div class="max-w-7xl mx-auto">
<div class="space-y-6 md:grid md:grid-cols-2 lg:grid-cols-4 lg:gap-8">
<div class="md:col-span-2 lg:col-span-1">
<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">
<div>
<h3 class="text-lg font-bold mb-4">产品</h3>
<ul class="space-y-2 text-gray-400">
<li><a href="#" class="hover:text-white transition-colors">定制卫衣</a></li>
<li><a href="#" class="hover:text-white transition-colors">定制T恤</a></li>
<li><a href="#" class="hover:text-white transition-colors">定制包包</a></li>
</ul>
</div>
<div>
<h3 class="text-lg font-bold mb-4">支持</h3>
<ul class="space-y-2 text-gray-400">
<li><a href="#" class="hover:text-white transition-colors">物流说明</a></li>
<li><a href="#" class="hover:text-white transition-colors">客服帮助</a></li>
<li><a href="#" class="hover:text-white transition-colors">售后客服</a></li>
</ul>
</div>
<div>
<h3 class="text-lg font-bold mb-4">联系</h3>
<ul class="space-y-2 text-gray-400">
<li><a href="#" class="hover:text-white transition-colors cursor-pointer">联系我们</a></li>
<li><a href="#" class="hover:text-white transition-colors cursor-pointer">官方公众号</a></li>
</ul>
</div>
</div>
</div>
<div class="border-t border-gray-800 mt-8 lg:mt-12 pt-6 lg:pt-8 text-center text-gray-500 text-sm">
Copyright @Inkreach 印美达POD全球供应链 All Rights Reserved.
<a
href="https://beian.miit.gov.cn/#/Integrated/index"
target="_blank"
style="color: border-gray-800; margin: 0px 10px;"
>粤ICP备xxx号</a>
广州市印美达供应链有限公司版权所有
</div>
</div>
</footer>
</template>
<template>
<footer class="product-footer text-white py-10 lg:py-16 px-4 lg:px-6 bg-inkreach-homepage-5">
<div class="max-w-7xl mx-auto">
<div class="space-y-6 md:grid md:grid-cols-2 lg:grid-cols-4 lg:gap-8">
<div class="md:col-span-2 lg:col-span-1">
<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">
<div>
<h3 class="text-lg font-bold mb-4">产品</h3>
<ul class="space-y-2 text-gray-400">
<li><a href="#" class="hover:text-white transition-colors">定制卫衣</a></li>
<li><a href="#" class="hover:text-white transition-colors">定制T恤</a></li>
<li><a href="#" class="hover:text-white transition-colors">定制包包</a></li>
</ul>
</div>
<div>
<h3 class="text-lg font-bold mb-4">支持</h3>
<ul class="space-y-2 text-gray-400">
<li><a href="#" class="hover:text-white transition-colors">物流说明</a></li>
<li><a href="#" class="hover:text-white transition-colors">客服帮助</a></li>
<li><a href="#" class="hover:text-white transition-colors">售后客服</a></li>
</ul>
</div>
<div>
<h3 class="text-lg font-bold mb-4">联系</h3>
<ul class="space-y-2 text-gray-400">
<li><a href="#" class="hover:text-white transition-colors cursor-pointer">联系我们</a></li>
<li><a href="#" class="hover:text-white transition-colors cursor-pointer">官方公众号</a></li>
</ul>
</div>
</div>
</div>
<div class="border-t border-gray-800 mt-8 lg:mt-12 pt-6 lg:pt-8 text-center text-gray-500 text-sm">
Copyright @Inkreach 印美达POD全球供应链 All Rights Reserved.
<a
href="https://beian.miit.gov.cn/#/Integrated/index"
target="_blank"
style="color: border-gray-800; margin: 0px 10px;"
>粤ICP备xxx号</a>
广州市印美达供应链有限公司版权所有
</div>
</div>
</footer>
</template>
+348 -348
View File
@@ -1,348 +1,348 @@
<script setup lang="ts">
import type { RecommendCategory, SolutionColumn, NavBanner } from '~/composables/useNavData';
const PORTAL_URL = 'https://inkpod.vip/portal/search';
const SHOW_EXTENDED_NAV = false;
const mobileMenuOpen = ref(false);
const recommendMenuOpen = ref(false);
const solutionMenuOpen = ref(false);
const mobileRecommendOpen = ref(false);
const mobileSolutionOpen = ref(false);
const mobileRecommendTab = ref(0);
const {
recommendCategories,
solutionColumns,
solutionBanner,
activeRecommendIdx,
setActiveRecommend,
} = useNavData();
let recommendTimeout: ReturnType<typeof setTimeout> | null = null;
let solutionTimeout: ReturnType<typeof setTimeout> | null = null;
function openRecommendMenu(): void {
if (recommendTimeout) {
clearTimeout(recommendTimeout);
recommendTimeout = null;
}
recommendMenuOpen.value = true;
}
function closeRecommendMenu(): void {
recommendTimeout = setTimeout(() => {
recommendMenuOpen.value = false;
}, 150);
}
function openSolutionMenu(): void {
if (solutionTimeout) {
clearTimeout(solutionTimeout);
solutionTimeout = null;
}
solutionMenuOpen.value = true;
}
function closeSolutionMenu(): void {
solutionTimeout = setTimeout(() => {
solutionMenuOpen.value = false;
}, 150);
}
function toggleMobileMenu(): void {
mobileMenuOpen.value = !mobileMenuOpen.value;
}
function closeMobileMenu(): void {
mobileMenuOpen.value = false;
mobileRecommendOpen.value = false;
mobileSolutionOpen.value = false;
}
function goToLogin(): void {
window.location.href = 'https://inkpod.vip/user/login';
}
function toggleMobileRecommend(): void {
mobileRecommendOpen.value = !mobileRecommendOpen.value;
mobileSolutionOpen.value = false;
}
function toggleMobileSolution(): void {
mobileSolutionOpen.value = !mobileSolutionOpen.value;
mobileRecommendOpen.value = false;
}
const mobileActiveCategory = computed(
() => recommendCategories.value[mobileRecommendTab.value] ?? null,
);
const route = useRoute();
const isProductCenter = computed(() => route.path === '/product-center');
</script>
<template>
<header class="pl-6 md:pl-10 lg:pl-16 pr-6 md:pr-10 lg:pr-20 flex items-center h-20 fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-100">
<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="desktop-nav hidden lg:flex gap-6 flex-1 ml-8 items-center h-20">
<NuxtLink to="/product-center"
class="transition-colors"
:class="isProductCenter
? 'text-black font-medium'
: 'text-gray-400 hover:text-primary hover:underline underline-offset-14 decoration-primary decoration-2'"
>产品中心</NuxtLink>
<a :href="PORTAL_URL" target="_blank"
class="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"
>
<button
class="transition-colors flex items-center gap-1"
:class="[
recommendMenuOpen ? 'text-primary' : '',
!recommendMenuOpen ? 'text-gray-400' : '',
]"
>
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">选品推荐</span>
<i class="fa-solid fa-chevron-down text-xs transition-transform duration-200" :class="recommendMenuOpen ? 'rotate-180' : ''"></i>
</button>
<NavMegaMenu
:categories="recommendCategories"
:active-index="activeRecommendIdx"
:visible="recommendMenuOpen"
@update:active-index="setActiveRecommend"
/>
</div>
<div
v-if="SHOW_EXTENDED_NAV"
class="relative h-full flex items-center"
@mouseenter="openSolutionMenu"
@mouseleave="closeSolutionMenu"
>
<button
class="transition-colors flex items-center gap-1"
:class="[
solutionMenuOpen ? 'text-primary' : '',
!solutionMenuOpen ? 'text-gray-400' : '',
]"
>
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">解决方案</span>
<i class="fa-solid fa-chevron-down text-xs transition-transform duration-200" :class="solutionMenuOpen ? 'rotate-180' : ''"></i>
</button>
<NavColumnMenu
:columns="solutionColumns"
:banner="solutionBanner"
:visible="solutionMenuOpen"
/>
</div>
</nav>
<div class="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"
>登录</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"
>立即开始</button>
</div>
<button
class="lg:hidden ml-auto p-2"
@click="toggleMobileMenu"
aria-label="Toggle menu"
>
<i v-if="!mobileMenuOpen" class="fa-solid fa-bars text-2xl"></i>
<i v-else class="fa-solid fa-xmark text-2xl"></i>
</button>
<Teleport to="body">
<Transition name="fade">
<div
v-if="mobileMenuOpen"
class="fixed inset-0 bg-black/50 z-40 lg:hidden"
@click="closeMobileMenu"
></div>
</Transition>
</Teleport>
<Transition name="slide">
<div
v-if="mobileMenuOpen"
class="fixed top-0 right-0 h-full w-80 bg-white shadow-2xl z-50 lg:hidden flex flex-col overflow-y-auto"
>
<div class="flex justify-between items-center p-6 pb-4">
<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>
</div>
<nav class="flex flex-col px-6 gap-1 text-lg">
<NuxtLink
to="/product-center"
class="py-3 hover:text-primary transition-colors"
@click="closeMobileMenu"
>产品中心</NuxtLink>
<a :href="PORTAL_URL" target="_blank"
class="py-3 hover:text-primary transition-colors"
>在线设计</a>
<!-- 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"
>
<span>选品推荐</span>
<i
class="fa-solid fa-chevron-down text-xs transition-transform duration-200"
:class="mobileRecommendOpen ? 'rotate-180' : ''"
></i>
</button>
<Transition name="accordion">
<div v-if="mobileRecommendOpen" class="pl-3 pb-2">
<div class="flex gap-2 mb-3 flex-wrap">
<button
v-for="(cat, idx) in recommendCategories"
:key="cat.id"
class="px-3 py-1.5 rounded-full text-sm transition-colors"
:class="mobileRecommendTab === idx
? 'bg-primary text-white'
: 'bg-inkreach-homepage-1 text-gray-600'"
@click="mobileRecommendTab = idx"
>
{{ cat.title }}
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<a
v-for="item in mobileActiveCategory?.items ?? []"
:key="item.id"
:href="item.link"
target="_blank"
class="group block rounded-lg overflow-hidden"
>
<div class="w-full h-[70px] rounded-lg overflow-hidden bg-inkreach-homepage-1">
<img
:src="item.image"
:alt="item.title"
class="w-full h-full object-cover"
/>
</div>
<p class="mt-1 text-[13px] text-gray-700 group-hover:text-primary truncate">
{{ item.title }}
</p>
</a>
</div>
</div>
</Transition>
</div>
<!-- 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"
>
<span>解决方案</span>
<i
class="fa-solid fa-chevron-down text-xs transition-transform duration-200"
:class="mobileSolutionOpen ? 'rotate-180' : ''"
></i>
</button>
<Transition name="accordion">
<div v-if="mobileSolutionOpen" class="pl-3 pb-2">
<div
v-for="col in solutionColumns"
:key="col.id"
class="mb-3"
>
<h4 class="text-[15px] font-medium text-gray-800 mb-2">
{{ col.title }}
</h4>
<ul class="space-y-1.5">
<li v-for="link in col.links" :key="link.id">
<a
:href="link.link"
target="_blank"
class="text-[14px] text-gray-600 hover:text-primary transition-colors flex items-center gap-1"
>
{{ link.title }}
<span
v-if="link.tag === 'hot'"
class="text-[10px] text-white bg-red-500 px-1 py-0.5 rounded leading-none"
>HOT</span>
<span
v-if="link.tag === 'new'"
class="text-[10px] text-white bg-green-500 px-1 py-0.5 rounded leading-none"
>NEW</span>
</a>
</li>
</ul>
</div>
</div>
</Transition>
</div>
</nav>
<div class="flex flex-col gap-4 px-6 mt-auto pb-6 pt-4">
<button type="button" @click="goToLogin"
class="py-2.5 text-lg hover:text-primary transition-colors text-center"
>登录</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"
>立即开始</button>
</div>
</div>
</Transition>
</header>
</template>
<style scoped>
.desktop-nav { font-size: 18px; }
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
}
.accordion-enter-active,
.accordion-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.accordion-enter-from,
.accordion-leave-to {
opacity: 0;
max-height: 0;
}
.accordion-enter-to,
.accordion-leave-from {
max-height: 600px;
}
</style>
<script setup lang="ts">
import type { RecommendCategory, SolutionColumn, NavBanner } from '~/composables/useNavData';
const PORTAL_URL = 'https://inkpod.vip/portal/search';
const SHOW_EXTENDED_NAV = false;
const mobileMenuOpen = ref(false);
const recommendMenuOpen = ref(false);
const solutionMenuOpen = ref(false);
const mobileRecommendOpen = ref(false);
const mobileSolutionOpen = ref(false);
const mobileRecommendTab = ref(0);
const {
recommendCategories,
solutionColumns,
solutionBanner,
activeRecommendIdx,
setActiveRecommend,
} = useNavData();
let recommendTimeout: ReturnType<typeof setTimeout> | null = null;
let solutionTimeout: ReturnType<typeof setTimeout> | null = null;
function openRecommendMenu(): void {
if (recommendTimeout) {
clearTimeout(recommendTimeout);
recommendTimeout = null;
}
recommendMenuOpen.value = true;
}
function closeRecommendMenu(): void {
recommendTimeout = setTimeout(() => {
recommendMenuOpen.value = false;
}, 150);
}
function openSolutionMenu(): void {
if (solutionTimeout) {
clearTimeout(solutionTimeout);
solutionTimeout = null;
}
solutionMenuOpen.value = true;
}
function closeSolutionMenu(): void {
solutionTimeout = setTimeout(() => {
solutionMenuOpen.value = false;
}, 150);
}
function toggleMobileMenu(): void {
mobileMenuOpen.value = !mobileMenuOpen.value;
}
function closeMobileMenu(): void {
mobileMenuOpen.value = false;
mobileRecommendOpen.value = false;
mobileSolutionOpen.value = false;
}
function goToLogin(): void {
window.location.href = 'https://inkpod.vip/user/login';
}
function toggleMobileRecommend(): void {
mobileRecommendOpen.value = !mobileRecommendOpen.value;
mobileSolutionOpen.value = false;
}
function toggleMobileSolution(): void {
mobileSolutionOpen.value = !mobileSolutionOpen.value;
mobileRecommendOpen.value = false;
}
const mobileActiveCategory = computed(
() => recommendCategories.value[mobileRecommendTab.value] ?? null,
);
const route = useRoute();
const isProductCenter = computed(() => route.path === '/product-center');
</script>
<template>
<header class="pl-6 md:pl-10 lg:pl-16 pr-6 md:pr-10 lg:pr-20 flex items-center h-20 fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-100">
<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="desktop-nav hidden lg:flex gap-6 flex-1 ml-8 items-center h-20">
<NuxtLink to="/product-center"
class="transition-colors"
:class="isProductCenter
? 'text-black font-medium'
: 'text-gray-400 hover:text-primary hover:underline underline-offset-14 decoration-primary decoration-2'"
>产品中心</NuxtLink>
<a :href="PORTAL_URL" target="_blank"
class="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"
>
<button
class="transition-colors flex items-center gap-1"
:class="[
recommendMenuOpen ? 'text-primary' : '',
!recommendMenuOpen ? 'text-gray-400' : '',
]"
>
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">选品推荐</span>
<i class="fa-solid fa-chevron-down text-xs transition-transform duration-200" :class="recommendMenuOpen ? 'rotate-180' : ''"></i>
</button>
<NavMegaMenu
:categories="recommendCategories"
:active-index="activeRecommendIdx"
:visible="recommendMenuOpen"
@update:active-index="setActiveRecommend"
/>
</div>
<div
v-if="SHOW_EXTENDED_NAV"
class="relative h-full flex items-center"
@mouseenter="openSolutionMenu"
@mouseleave="closeSolutionMenu"
>
<button
class="transition-colors flex items-center gap-1"
:class="[
solutionMenuOpen ? 'text-primary' : '',
!solutionMenuOpen ? 'text-gray-400' : '',
]"
>
<span class="hover:underline underline-offset-14 decoration-primary decoration-2">解决方案</span>
<i class="fa-solid fa-chevron-down text-xs transition-transform duration-200" :class="solutionMenuOpen ? 'rotate-180' : ''"></i>
</button>
<NavColumnMenu
:columns="solutionColumns"
:banner="solutionBanner"
:visible="solutionMenuOpen"
/>
</div>
</nav>
<div class="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"
>登录</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"
>立即开始</button>
</div>
<button
class="lg:hidden ml-auto p-2"
@click="toggleMobileMenu"
aria-label="Toggle menu"
>
<i v-if="!mobileMenuOpen" class="fa-solid fa-bars text-2xl"></i>
<i v-else class="fa-solid fa-xmark text-2xl"></i>
</button>
<Teleport to="body">
<Transition name="fade">
<div
v-if="mobileMenuOpen"
class="fixed inset-0 bg-black/50 z-40 lg:hidden"
@click="closeMobileMenu"
></div>
</Transition>
</Teleport>
<Transition name="slide">
<div
v-if="mobileMenuOpen"
class="fixed top-0 right-0 h-full w-80 bg-white shadow-2xl z-50 lg:hidden flex flex-col overflow-y-auto"
>
<div class="flex justify-between items-center p-6 pb-4">
<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>
</div>
<nav class="flex flex-col px-6 gap-1 text-lg">
<NuxtLink
to="/product-center"
class="py-3 hover:text-primary transition-colors"
@click="closeMobileMenu"
>产品中心</NuxtLink>
<a :href="PORTAL_URL" target="_blank"
class="py-3 hover:text-primary transition-colors"
>在线设计</a>
<!-- 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"
>
<span>选品推荐</span>
<i
class="fa-solid fa-chevron-down text-xs transition-transform duration-200"
:class="mobileRecommendOpen ? 'rotate-180' : ''"
></i>
</button>
<Transition name="accordion">
<div v-if="mobileRecommendOpen" class="pl-3 pb-2">
<div class="flex gap-2 mb-3 flex-wrap">
<button
v-for="(cat, idx) in recommendCategories"
:key="cat.id"
class="px-3 py-1.5 rounded-full text-sm transition-colors"
:class="mobileRecommendTab === idx
? 'bg-primary text-white'
: 'bg-inkreach-homepage-1 text-gray-600'"
@click="mobileRecommendTab = idx"
>
{{ cat.title }}
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<a
v-for="item in mobileActiveCategory?.items ?? []"
:key="item.id"
:href="item.link"
target="_blank"
class="group block rounded-lg overflow-hidden"
>
<div class="w-full h-[70px] rounded-lg overflow-hidden bg-inkreach-homepage-1">
<img
:src="item.image"
:alt="item.title"
class="w-full h-full object-cover"
/>
</div>
<p class="mt-1 text-[13px] text-gray-700 group-hover:text-primary truncate">
{{ item.title }}
</p>
</a>
</div>
</div>
</Transition>
</div>
<!-- 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"
>
<span>解决方案</span>
<i
class="fa-solid fa-chevron-down text-xs transition-transform duration-200"
:class="mobileSolutionOpen ? 'rotate-180' : ''"
></i>
</button>
<Transition name="accordion">
<div v-if="mobileSolutionOpen" class="pl-3 pb-2">
<div
v-for="col in solutionColumns"
:key="col.id"
class="mb-3"
>
<h4 class="text-[15px] font-medium text-gray-800 mb-2">
{{ col.title }}
</h4>
<ul class="space-y-1.5">
<li v-for="link in col.links" :key="link.id">
<a
:href="link.link"
target="_blank"
class="text-[14px] text-gray-600 hover:text-primary transition-colors flex items-center gap-1"
>
{{ link.title }}
<span
v-if="link.tag === 'hot'"
class="text-[10px] text-white bg-red-500 px-1 py-0.5 rounded leading-none"
>HOT</span>
<span
v-if="link.tag === 'new'"
class="text-[10px] text-white bg-green-500 px-1 py-0.5 rounded leading-none"
>NEW</span>
</a>
</li>
</ul>
</div>
</div>
</Transition>
</div>
</nav>
<div class="flex flex-col gap-4 px-6 mt-auto pb-6 pt-4">
<button type="button" @click="goToLogin"
class="py-2.5 text-lg hover:text-primary transition-colors text-center"
>登录</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"
>立即开始</button>
</div>
</div>
</Transition>
</header>
</template>
<style scoped>
.desktop-nav { font-size: 18px; }
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-enter-active,
.slide-leave-active {
transition: transform 0.3s ease;
}
.slide-enter-from,
.slide-leave-to {
transform: translateX(100%);
}
.accordion-enter-active,
.accordion-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.accordion-enter-from,
.accordion-leave-to {
opacity: 0;
max-height: 0;
}
.accordion-enter-to,
.accordion-leave-from {
max-height: 600px;
}
</style>
+54 -54
View File
@@ -1,54 +1,54 @@
<template>
<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">
<div>
<h3
class="text-xl lg:text-2xl font-bold mb-4"
style="background: linear-gradient(90deg, #000000, #ec6000); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent;"
>
用科技重新定义服装供应链 <br /><span>让每一个创意都能高效抵达全球</span>
</h3>
<p class="text-gray-600">Inkreach印美达全球, 一家以AI与智能印花技术驱动的全球化POD柔性供应链公司, 致力于成为全球创业者最可靠的成长伙伴</p>
</div>
<div>
<h3 class="text-lg lg:text-xl font-bold text-gray-900 mb-4">服务对象与场景</h3>
<p class="text-gray-600">服务于数千家跨境独立站TikTokTEMUSHEIN等平台商家, 提供从海外生产至末端物流的一站式供应链服务, 让电商创业者专注增长, 无惧后端履约</p>
</div>
<div>
<h3 class="text-lg lg:text-xl font-bold text-gray-900 mb-4">愿景</h3>
<p class="text-gray-600">我们坚信, 每一个创意都值得被完美呈现INKREACH将持续深耕智能供应链生态, 用科技消除制造壁垒, 让全球创业者专注发挥创造力, 共同书写个性化电商的新纪元</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
<div
v-for="cap in capabilities"
:key="cap.title"
class="bg-white border border-gray-100 rounded-xl p-5 lg:p-6"
>
<div class="flex items-center gap-3 mb-4">
<div class="w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
<i :class="cap.icon" class="text-primary"></i>
</div>
<h3 class="text-base lg:text-lg font-bold text-gray-900">{{ cap.title }}</h3>
</div>
<p class="text-gray-600 text-sm lg:text-base">{{ cap.desc }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup>
const capabilities = [
{ title: '前沿印花技术', desc: '搭载最新智能印花工艺,品质与效率兼得', icon: 'fa-solid fa-print' },
{ title: '深度AI应用', desc: '从设计至生产, 全链路智能化提效', icon: 'fa-solid fa-brain' },
{ title: '全球智能工厂', desc: '多国自营本土化POD工厂, 日均产能超20万单', icon: 'fa-solid fa-industry' },
{ title: '一站式柔性交付', desc: '覆盖生产至物流, 助力品牌从概念到商业落地', icon: 'fa-solid fa-handshake' },
];
</script>
<style scoped>
@media (min-width: 1024px) { .company-section { height: 600px; } }
</style>
<template>
<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">
<div>
<h3
class="text-xl lg:text-2xl font-bold mb-4"
style="background: linear-gradient(90deg, #000000, #ec6000); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent;"
>
用科技重新定义服装供应链 <br /><span>让每一个创意都能高效抵达全球</span>
</h3>
<p class="text-gray-600">Inkreach印美达全球, 一家以AI与智能印花技术驱动的全球化POD柔性供应链公司, 致力于成为全球创业者最可靠的成长伙伴</p>
</div>
<div>
<h3 class="text-lg lg:text-xl font-bold text-gray-900 mb-4">服务对象与场景</h3>
<p class="text-gray-600">服务于数千家跨境独立站TikTokTEMUSHEIN等平台商家, 提供从海外生产至末端物流的一站式供应链服务, 让电商创业者专注增长, 无惧后端履约</p>
</div>
<div>
<h3 class="text-lg lg:text-xl font-bold text-gray-900 mb-4">愿景</h3>
<p class="text-gray-600">我们坚信, 每一个创意都值得被完美呈现INKREACH将持续深耕智能供应链生态, 用科技消除制造壁垒, 让全球创业者专注发挥创造力, 共同书写个性化电商的新纪元</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
<div
v-for="cap in capabilities"
:key="cap.title"
class="bg-white border border-gray-100 rounded-xl p-5 lg:p-6"
>
<div class="flex items-center gap-3 mb-4">
<div class="w-8 h-8 bg-primary/10 rounded-lg flex items-center justify-center">
<i :class="cap.icon" class="text-primary"></i>
</div>
<h3 class="text-base lg:text-lg font-bold text-gray-900">{{ cap.title }}</h3>
</div>
<p class="text-gray-600 text-sm lg:text-base">{{ cap.desc }}</p>
</div>
</div>
</div>
</section>
</template>
<script setup>
const capabilities = [
{ title: '前沿印花技术', desc: '搭载最新智能印花工艺,品质与效率兼得', icon: 'fa-solid fa-print' },
{ title: '深度AI应用', desc: '从设计至生产, 全链路智能化提效', icon: 'fa-solid fa-brain' },
{ title: '全球智能工厂', desc: '多国自营本土化POD工厂, 日均产能超20万单', icon: 'fa-solid fa-industry' },
{ title: '一站式柔性交付', desc: '覆盖生产至物流, 助力品牌从概念到商业落地', icon: 'fa-solid fa-handshake' },
];
</script>
<style scoped>
@media (min-width: 1024px) { .company-section { height: 600px; } }
</style>
+26 -26
View File
@@ -1,26 +1,26 @@
<script setup lang="ts">
function goToLogin(): void {
window.location.href = 'https://inkpod.vip/user/login';
}
</script>
<template>
<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"
>
立即免费使用
<i class="fa-solid fa-arrow-right ml-2"></i>
</button>
</div>
</section>
</template>
<style scoped>
@media (min-width: 1024px) { .cta-section { height: 338px; padding-top: 72px; } }
</style>
<script setup lang="ts">
function goToLogin(): void {
window.location.href = 'https://inkpod.vip/user/login';
}
</script>
<template>
<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"
>
立即免费使用
<i class="fa-solid fa-arrow-right ml-2"></i>
</button>
</div>
</section>
</template>
<style scoped>
@media (min-width: 1024px) { .cta-section { height: 338px; padding-top: 72px; } }
</style>
+125 -125
View File
@@ -1,125 +1,125 @@
<script setup>
const customerCases = ref([
{ 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="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" aria-label="用户案例自动轮播">
<div class="case-track flex gap-8 w-max">
<div
v-for="(testCase, idx) in [...customerCases, ...customerCases]"
:key="'a-' + idx"
class="case-card relative flex-shrink-0 w-[350px] h-[420px] bg-white rounded-xl overflow-hidden"
>
<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']"
>
{{ testCase.platform }}
</span>
<img :src="testCase.image" :alt="testCase.title" class="w-full h-full object-cover" />
</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="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>
<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-hidden pb-4 px-4" aria-label="用户案例自动轮播">
<div class="case-track-mobile flex gap-4 w-max">
<div
v-for="(testCase, idx) in [...customerCases, ...customerCases]"
:key="'m-' + idx"
class="flex-shrink-0 w-[280px] bg-white rounded-xl overflow-hidden"
>
<div class="relative h-36">
<span
:class="['absolute top-3 left-3', testCase.isPrimary ? 'bg-primary' : 'bg-black', 'text-white px-2 py-1 rounded-full text-xs font-medium']"
>
{{ testCase.platform }}
</span>
<img :src="testCase.image" :alt="testCase.title" class="w-full h-full object-cover" />
</div>
<div class="p-4">
<div class="flex justify-between items-center mb-3">
<div>
<p class="text-gray-500 text-xs">销量增长</p>
<p class="text-xl font-bold text-primary">{{ testCase.salesGrowth }}</p>
</div>
<div class="text-right">
<p class="text-gray-500 text-xs">月销售额</p>
<p class="text-xl font-bold text-primary">{{ testCase.monthlySales }}</p>
</div>
</div>
<h3 class="text-base font-bold text-gray-900 mb-1">{{ testCase.title }}</h3>
<p class="text-gray-600 text-xs">{{ testCase.description }}</p>
</div>
</div>
</div>
</div>
</section>
</template>
<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>
<script setup>
const customerCases = ref([
{ 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="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" aria-label="用户案例自动轮播">
<div class="case-track flex gap-8 w-max">
<div
v-for="(testCase, idx) in [...customerCases, ...customerCases]"
:key="'a-' + idx"
class="case-card relative flex-shrink-0 w-[350px] h-[420px] bg-white rounded-xl overflow-hidden"
>
<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']"
>
{{ testCase.platform }}
</span>
<img :src="testCase.image" :alt="testCase.title" class="w-full h-full object-cover" />
</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="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>
<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-hidden pb-4 px-4" aria-label="用户案例自动轮播">
<div class="case-track-mobile flex gap-4 w-max">
<div
v-for="(testCase, idx) in [...customerCases, ...customerCases]"
:key="'m-' + idx"
class="flex-shrink-0 w-[280px] bg-white rounded-xl overflow-hidden"
>
<div class="relative h-36">
<span
:class="['absolute top-3 left-3', testCase.isPrimary ? 'bg-primary' : 'bg-black', 'text-white px-2 py-1 rounded-full text-xs font-medium']"
>
{{ testCase.platform }}
</span>
<img :src="testCase.image" :alt="testCase.title" class="w-full h-full object-cover" />
</div>
<div class="p-4">
<div class="flex justify-between items-center mb-3">
<div>
<p class="text-gray-500 text-xs">销量增长</p>
<p class="text-xl font-bold text-primary">{{ testCase.salesGrowth }}</p>
</div>
<div class="text-right">
<p class="text-gray-500 text-xs">月销售额</p>
<p class="text-xl font-bold text-primary">{{ testCase.monthlySales }}</p>
</div>
</div>
<h3 class="text-base font-bold text-gray-900 mb-1">{{ testCase.title }}</h3>
<p class="text-gray-600 text-xs">{{ testCase.description }}</p>
</div>
</div>
</div>
</div>
</section>
</template>
<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>
+77 -77
View File
@@ -1,77 +1,77 @@
<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' },
{ title: '自动化履约', desc: '从生产到配送,全程无忧,准时交付', image: '/自动化履约.png' },
{ title: '全球配送', desc: '全球20+国家本土工厂', image: '/全球配送.png' },
{ title: '店铺集成', desc: '与Shopify、Amazon等平台无缝对接', image: '/店铺集成.png' },
];
const selectFeature = (idx: number) => {
activeFeature.value = idx;
};
let featureTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
featureTimer = setInterval(() => {
activeFeature.value = (activeFeature.value + 1) % features.length;
}, FEATURE_INTERVAL_MS);
});
onBeforeUnmount(() => {
if (featureTimer) clearInterval(featureTimer);
});
</script>
<template>
<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="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-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="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>
<div class="flex-1 overflow-hidden rounded-xl relative">
<img
v-for="(feat, idx) in features"
:key="'feat-img-' + idx"
:src="feat.image"
:alt="feat.title"
: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'"
/>
</div>
</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>
<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' },
{ title: '自动化履约', desc: '从生产到配送,全程无忧,准时交付', image: '/自动化履约.png' },
{ title: '全球配送', desc: '全球20+国家本土工厂', image: '/全球配送.png' },
{ title: '店铺集成', desc: '与Shopify、Amazon等平台无缝对接', image: '/店铺集成.png' },
];
const selectFeature = (idx: number) => {
activeFeature.value = idx;
};
let featureTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
featureTimer = setInterval(() => {
activeFeature.value = (activeFeature.value + 1) % features.length;
}, FEATURE_INTERVAL_MS);
});
onBeforeUnmount(() => {
if (featureTimer) clearInterval(featureTimer);
});
</script>
<template>
<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="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-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="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>
<div class="flex-1 overflow-hidden rounded-xl relative">
<img
v-for="(feat, idx) in features"
:key="'feat-img-' + idx"
:src="feat.image"
:alt="feat.title"
: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'"
/>
</div>
</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>
+79 -79
View File
@@ -1,79 +1,79 @@
<script setup lang="ts">
function goToLogin(): void {
window.location.href = 'https://inkpod.vip/user/login';
}
</script>
<template>
<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=""
class="hidden lg:block absolute left-[10%] top-2/6 -translate-y-1/2"
/>
<img
src="/帆布袋.png"
alt=""
class="hidden lg:block absolute right-[10%] top-1/4 -translate-y-2/3"
/>
<div class="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>
<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"
>
免费试用
</button>
<img
src="/鸭舌帽.png"
alt=""
class="hidden lg:block absolute left-full -ml-9 top-1/2 -translate-y-1/2"
/>
</div>
<img
src="/主视觉.png"
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>
<script setup lang="ts">
function goToLogin(): void {
window.location.href = 'https://inkpod.vip/user/login';
}
</script>
<template>
<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=""
class="hidden lg:block absolute left-[10%] top-2/6 -translate-y-1/2"
/>
<img
src="/帆布袋.png"
alt=""
class="hidden lg:block absolute right-[10%] top-1/4 -translate-y-2/3"
/>
<div class="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>
<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"
>
免费试用
</button>
<img
src="/鸭舌帽.png"
alt=""
class="hidden lg:block absolute left-full -ml-9 top-1/2 -translate-y-1/2"
/>
</div>
<img
src="/主视觉.png"
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>
+120 -120
View File
@@ -1,120 +1,120 @@
<script setup lang="ts">
const {
countryGroups,
loading,
loadingProducts,
currentProducts,
fetchCategories,
selectCountry,
} = usePodProducts();
const { countries: configuredCountries, categories: configuredCategories, loadFilters } = useProductCenter();
const selectedCountryId = shallowRef<string>();
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="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="country-grid grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 pb-2">
<button
v-for="country in configuredCountries"
:key="country.id"
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)"
>
<span class="country-flag">
<img v-if="country.icon" :src="country.icon" :alt="country.name" />
</span>
<span>{{ country.name }}</span>
</button>
</div>
<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">
<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">
<img
v-if="product.image"
:src="product.image"
:alt="product.name"
class="max-w-full max-h-full"
loading="lazy"
/>
<div v-else class="flex items-center justify-center text-gray-300">
<i class="fa-solid fa-image text-4xl"></i>
</div>
</div>
<div class="w-full text-xs lg:text-sm h-8 lg:h-10 flex justify-center items-center px-1 truncate">
<div class="truncate">{{ product.name }}</div>
</div>
</NuxtLink>
</div>
</div>
<div class="more-products w-full flex justify-center py-6 lg:py-10">
<NuxtLink
to="/product-center"
class="more-products-link text-primary text-base lg:text-lg font-medium hover:underline underline-offset-4"
>
更多产品
</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>
<script setup lang="ts">
const {
countryGroups,
loading,
loadingProducts,
currentProducts,
fetchCategories,
selectCountry,
} = usePodProducts();
const { countries: configuredCountries, categories: configuredCategories, loadFilters } = useProductCenter();
const selectedCountryId = shallowRef<string>();
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="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="country-grid grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 pb-2">
<button
v-for="country in configuredCountries"
:key="country.id"
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)"
>
<span class="country-flag">
<img v-if="country.icon" :src="country.icon" :alt="country.name" />
</span>
<span>{{ country.name }}</span>
</button>
</div>
<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">
<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">
<img
v-if="product.image"
:src="product.image"
:alt="product.name"
class="max-w-full max-h-full"
loading="lazy"
/>
<div v-else class="flex items-center justify-center text-gray-300">
<i class="fa-solid fa-image text-4xl"></i>
</div>
</div>
<div class="w-full text-xs lg:text-sm h-8 lg:h-10 flex justify-center items-center px-1 truncate">
<div class="truncate">{{ product.name }}</div>
</div>
</NuxtLink>
</div>
</div>
<div class="more-products w-full flex justify-center py-6 lg:py-10">
<NuxtLink
to="/product-center"
class="more-products-link text-primary text-base lg:text-lg font-medium hover:underline underline-offset-4"
>
更多产品
</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>
+98 -98
View File
@@ -1,98 +1,98 @@
<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' },
{ num: '2', title: '上传设计', desc: '可视化编辑器,轻松实现创意落地', image: '/02步骤-上传设计.png' },
{ num: '3', title: '在线销售', desc: '一键开通店铺,多渠道触达目标客群', image: '/03步骤-在线销售.png' },
{ num: '4', title: '生产配送', desc: '智能化柔性生产,快速响应全球配送', image: '/04步骤-生产配送.png' },
];
const selectStep = (idx: number) => {
activeStep.value = idx;
startStepTimer();
};
let stepTimer: ReturnType<typeof setInterval> | null = null;
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;
}, STEP_INTERVAL_MS);
}
onMounted(startStepTimer);
onBeforeUnmount(() => {
if (stepTimer) clearInterval(stepTimer);
});
</script>
<template>
<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-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="selectStep(idx)"
>
<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 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 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>
</div>
<div class="flex-1 overflow-hidden rounded-xl relative">
<img
v-for="(step, idx) in steps"
:key="'img-' + idx"
:src="step.image"
:alt="step.title"
: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'"
/>
</div>
</div>
<div class="flex lg:hidden">
<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>
<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' },
{ num: '2', title: '上传设计', desc: '可视化编辑器,轻松实现创意落地', image: '/02步骤-上传设计.png' },
{ num: '3', title: '在线销售', desc: '一键开通店铺,多渠道触达目标客群', image: '/03步骤-在线销售.png' },
{ num: '4', title: '生产配送', desc: '智能化柔性生产,快速响应全球配送', image: '/04步骤-生产配送.png' },
];
const selectStep = (idx: number) => {
activeStep.value = idx;
startStepTimer();
};
let stepTimer: ReturnType<typeof setInterval> | null = null;
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;
}, STEP_INTERVAL_MS);
}
onMounted(startStepTimer);
onBeforeUnmount(() => {
if (stepTimer) clearInterval(stepTimer);
});
</script>
<template>
<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-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="selectStep(idx)"
>
<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 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 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>
</div>
<div class="flex-1 overflow-hidden rounded-xl relative">
<img
v-for="(step, idx) in steps"
:key="'img-' + idx"
:src="step.image"
:alt="step.title"
: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'"
/>
</div>
</div>
<div class="flex lg:hidden">
<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>
+39 -39
View File
@@ -1,39 +1,39 @@
<template>
<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
v-for="item in stats"
:key="item.label"
class="bg-white overflow-hidden rounded-lg"
>
<div class="bg-inkreach-homepage-3">
<div class="text-lg lg:text-2xl flex flex-col gap-1 lg:gap-2 p-3 lg:p-5">
<div class="trust-stat-value text-primary font-black">{{ item.value }}</div>
<div>{{ item.label }}</div>
</div>
</div>
<div class="trust-illustration relative -top-6 lg:-top-10">
<img :src="item.icon" :alt="item.label" />
</div>
</div>
</div>
</section>
</template>
<script setup>
const stats = [
{ value: '1000万+', label: '订单履约', icon: '/订单履约.png' },
{ value: '20+', label: '国家配送', icon: '/国家配送.png' },
{ value: '500+', label: '产品生产', icon: '/产品生产.png' },
{ value: '24H', label: '急速发货', icon: '/极速发货.png' },
];
</script>
<style scoped>
@media (min-width: 1024px) {
.trust-section { height: 600px; }
.trust-illustration { margin-bottom: -40px; }
}
</style>
<template>
<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
v-for="item in stats"
:key="item.label"
class="bg-white overflow-hidden rounded-lg"
>
<div class="bg-inkreach-homepage-3">
<div class="text-lg lg:text-2xl flex flex-col gap-1 lg:gap-2 p-3 lg:p-5">
<div class="trust-stat-value text-primary font-black">{{ item.value }}</div>
<div>{{ item.label }}</div>
</div>
</div>
<div class="trust-illustration relative -top-6 lg:-top-10">
<img :src="item.icon" :alt="item.label" />
</div>
</div>
</div>
</section>
</template>
<script setup>
const stats = [
{ value: '1000万+', label: '订单履约', icon: '/订单履约.png' },
{ value: '20+', label: '国家配送', icon: '/国家配送.png' },
{ value: '500+', label: '产品生产', icon: '/产品生产.png' },
{ value: '24H', label: '急速发货', icon: '/极速发货.png' },
];
</script>
<style scoped>
@media (min-width: 1024px) {
.trust-section { height: 600px; }
.trust-illustration { margin-bottom: -40px; }
}
</style>
+36 -36
View File
@@ -1,36 +1,36 @@
<template>
<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
style="background: linear-gradient(90deg, #000000, #ec6000); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent;"
>inkreach?</span>
</h2>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
<div
v-for="item in reasons"
:key="item.title"
class="bg-white rounded-xl p-6 lg:p-10 text-center flex flex-col justify-center items-center gap-3"
>
<div class="rounded-full flex items-center justify-center">
<img :src="item.icon" :alt="item.title" class="w-8 h-8 lg:w-auto lg:h-auto" />
</div>
<h3 class="text-lg lg:text-2xl font-bold text-gray-900">{{ item.title }}</h3>
</div>
</div>
</div>
</section>
</template>
<script setup>
const reasons = [
{ title: '零库存', icon: '/零库存图标.png' },
{ title: '快速生产', icon: '/快速生产图标.png' },
{ title: '优质产品', icon: '/优质产品图标.png' },
{ title: '全球配送', icon: '/全球配送图标.png' },
];
</script>
<style scoped>
@media (min-width: 1024px) { .why-section { height: 430px; } }
</style>
<template>
<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
style="background: linear-gradient(90deg, #000000, #ec6000); background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent;"
>inkreach?</span>
</h2>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
<div
v-for="item in reasons"
:key="item.title"
class="bg-white rounded-xl p-6 lg:p-10 text-center flex flex-col justify-center items-center gap-3"
>
<div class="rounded-full flex items-center justify-center">
<img :src="item.icon" :alt="item.title" class="w-8 h-8 lg:w-auto lg:h-auto" />
</div>
<h3 class="text-lg lg:text-2xl font-bold text-gray-900">{{ item.title }}</h3>
</div>
</div>
</div>
</section>
</template>
<script setup>
const reasons = [
{ title: '零库存', icon: '/零库存图标.png' },
{ title: '快速生产', icon: '/快速生产图标.png' },
{ title: '优质产品', icon: '/优质产品图标.png' },
{ title: '全球配送', icon: '/全球配送图标.png' },
];
</script>
<style scoped>
@media (min-width: 1024px) { .why-section { height: 430px; } }
</style>
@@ -1,75 +1,75 @@
<script setup lang="ts">
import type { SolutionColumn, NavBanner } from '~/composables/useNavData';
defineProps<{
columns: SolutionColumn[];
banner: NavBanner | null;
visible: boolean;
}>();
</script>
<template>
<Transition name="nav-dropdown">
<div
v-if="visible && columns.length > 0"
class="absolute left-0 top-full bg-white rounded-lg shadow-[0_4px_25px_rgba(179,185,191,0.3)] border border-gray-200 flex z-50 p-6 gap-6"
>
<div class="flex gap-6">
<div
v-for="col in columns"
:key="col.id"
class="min-w-[150px]"
>
<h3 class="text-[15px] font-bold text-gray-800 pb-3 mb-3 border-b border-gray-200 whitespace-nowrap">
{{ col.title }}
</h3>
<ul class="space-y-2">
<li v-for="link in col.links" :key="link.id">
<a
:href="link.link"
target="_blank"
class="text-[14px] text-gray-600 hover:text-primary transition-colors flex items-center gap-1.5"
>
{{ link.title }}
<span
v-if="link.tag === 'hot'"
class="text-[10px] text-white bg-red-500 px-1.5 py-0.5 rounded leading-none"
>HOT</span>
<span
v-if="link.tag === 'new'"
class="text-[10px] text-white bg-green-500 px-1.5 py-0.5 rounded leading-none"
>NEW</span>
</a>
</li>
</ul>
</div>
</div>
<div v-if="banner" class="shrink-0 self-start">
<a
:href="banner.link"
target="_blank"
class="block w-[180px] h-[140px] rounded-lg overflow-hidden group"
>
<img
:src="banner.image"
:alt="banner.title"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</a>
</div>
</div>
</Transition>
</template>
<style scoped>
.nav-dropdown-enter-active,
.nav-dropdown-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.nav-dropdown-enter-from,
.nav-dropdown-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>
<script setup lang="ts">
import type { SolutionColumn, NavBanner } from '~/composables/useNavData';
defineProps<{
columns: SolutionColumn[];
banner: NavBanner | null;
visible: boolean;
}>();
</script>
<template>
<Transition name="nav-dropdown">
<div
v-if="visible && columns.length > 0"
class="absolute left-0 top-full bg-white rounded-lg shadow-[0_4px_25px_rgba(179,185,191,0.3)] border border-gray-200 flex z-50 p-6 gap-6"
>
<div class="flex gap-6">
<div
v-for="col in columns"
:key="col.id"
class="min-w-[150px]"
>
<h3 class="text-[15px] font-bold text-gray-800 pb-3 mb-3 border-b border-gray-200 whitespace-nowrap">
{{ col.title }}
</h3>
<ul class="space-y-2">
<li v-for="link in col.links" :key="link.id">
<a
:href="link.link"
target="_blank"
class="text-[14px] text-gray-600 hover:text-primary transition-colors flex items-center gap-1.5"
>
{{ link.title }}
<span
v-if="link.tag === 'hot'"
class="text-[10px] text-white bg-red-500 px-1.5 py-0.5 rounded leading-none"
>HOT</span>
<span
v-if="link.tag === 'new'"
class="text-[10px] text-white bg-green-500 px-1.5 py-0.5 rounded leading-none"
>NEW</span>
</a>
</li>
</ul>
</div>
</div>
<div v-if="banner" class="shrink-0 self-start">
<a
:href="banner.link"
target="_blank"
class="block w-[180px] h-[140px] rounded-lg overflow-hidden group"
>
<img
:src="banner.image"
:alt="banner.title"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</a>
</div>
</div>
</Transition>
</template>
<style scoped>
.nav-dropdown-enter-active,
.nav-dropdown-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.nav-dropdown-enter-from,
.nav-dropdown-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>
+83 -83
View File
@@ -1,83 +1,83 @@
<script setup lang="ts">
import type { RecommendCategory } from '~/composables/useNavData';
const props = defineProps<{
categories: RecommendCategory[];
activeIndex: number;
visible: boolean;
}>();
const emit = defineEmits<{
'update:activeIndex': [index: number];
}>();
function onCategoryHover(idx: number): void {
emit('update:activeIndex', idx);
}
const activeCategory = computed(
() => props.categories[props.activeIndex] ?? null,
);
</script>
<template>
<Transition name="nav-dropdown">
<div
v-if="visible && categories.length > 0"
class="absolute left-0 top-full bg-white rounded-lg shadow-[0_4px_25px_rgba(179,185,191,0.3)] border border-gray-200 flex z-50 overflow-hidden"
>
<ul class="w-[240px] border-r border-gray-200 py-3 shrink-0">
<li
v-for="(cat, idx) in categories"
:key="cat.id"
class="flex items-center justify-between px-6 py-4 cursor-pointer text-base transition-colors"
:class="idx === activeIndex
? 'pl-8 bg-inkreach-homepage-1 text-primary font-medium'
: 'text-gray-600 hover:text-primary'"
@mouseenter="onCategoryHover(idx)"
>
<span>{{ cat.title }}</span>
<i class="fa-solid fa-chevron-right text-xs opacity-40"></i>
</li>
</ul>
<div class="p-6 w-[720px]">
<p class="text-base text-gray-800 font-medium mb-4">
{{ activeCategory?.title }}
</p>
<div class="grid grid-cols-2 gap-4">
<a
v-for="item in activeCategory?.items ?? []"
:key="item.id"
:href="item.link"
target="_blank"
class="group block rounded-lg overflow-hidden"
>
<div class="w-full h-[130px] rounded-lg overflow-hidden bg-inkreach-homepage-1">
<img
:src="item.image"
:alt="item.title"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
<p class="mt-2 text-[15px] text-gray-700 group-hover:text-primary transition-colors truncate">
{{ item.title }}
</p>
</a>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.nav-dropdown-enter-active,
.nav-dropdown-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.nav-dropdown-enter-from,
.nav-dropdown-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>
<script setup lang="ts">
import type { RecommendCategory } from '~/composables/useNavData';
const props = defineProps<{
categories: RecommendCategory[];
activeIndex: number;
visible: boolean;
}>();
const emit = defineEmits<{
'update:activeIndex': [index: number];
}>();
function onCategoryHover(idx: number): void {
emit('update:activeIndex', idx);
}
const activeCategory = computed(
() => props.categories[props.activeIndex] ?? null,
);
</script>
<template>
<Transition name="nav-dropdown">
<div
v-if="visible && categories.length > 0"
class="absolute left-0 top-full bg-white rounded-lg shadow-[0_4px_25px_rgba(179,185,191,0.3)] border border-gray-200 flex z-50 overflow-hidden"
>
<ul class="w-[240px] border-r border-gray-200 py-3 shrink-0">
<li
v-for="(cat, idx) in categories"
:key="cat.id"
class="flex items-center justify-between px-6 py-4 cursor-pointer text-base transition-colors"
:class="idx === activeIndex
? 'pl-8 bg-inkreach-homepage-1 text-primary font-medium'
: 'text-gray-600 hover:text-primary'"
@mouseenter="onCategoryHover(idx)"
>
<span>{{ cat.title }}</span>
<i class="fa-solid fa-chevron-right text-xs opacity-40"></i>
</li>
</ul>
<div class="p-6 w-[720px]">
<p class="text-base text-gray-800 font-medium mb-4">
{{ activeCategory?.title }}
</p>
<div class="grid grid-cols-2 gap-4">
<a
v-for="item in activeCategory?.items ?? []"
:key="item.id"
:href="item.link"
target="_blank"
class="group block rounded-lg overflow-hidden"
>
<div class="w-full h-[130px] rounded-lg overflow-hidden bg-inkreach-homepage-1">
<img
:src="item.image"
:alt="item.title"
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
<p class="mt-2 text-[15px] text-gray-700 group-hover:text-primary transition-colors truncate">
{{ item.title }}
</p>
</a>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.nav-dropdown-enter-active,
.nav-dropdown-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.nav-dropdown-enter-from,
.nav-dropdown-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>
@@ -1,46 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Product } from '~/composables/useProductCenter';
import { sortTagsByGroup } from '~/composables/useProductCenter';
const props = defineProps<{ product: Product; showCountry?: boolean }>();
const 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>
<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" loading="lazy" />
<i v-else class="fa-regular fa-image" aria-hidden="true"></i>
</div>
<div class="product-info">
<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"><template v-if="price"><small></small>{{ price }}<small></small></template><template v-else>暂无报价</template></div>
</div>
</a>
</template>
<style scoped>
.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>
<script setup lang="ts">
import { computed } from 'vue';
import type { Product } from '~/composables/useProductCenter';
import { sortTagsByGroup } from '~/composables/useProductCenter';
const props = defineProps<{ product: Product; showCountry?: boolean }>();
const 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>
<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" loading="lazy" />
<i v-else class="fa-regular fa-image" aria-hidden="true"></i>
</div>
<div class="product-info">
<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"><template v-if="price"><small></small>{{ price }}<small></small></template><template v-else>暂无报价</template></div>
</div>
</a>
</template>
<style scoped>
.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,88 +1,88 @@
<template>
<article class="skeleton-card">
<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">
<span class="skeleton-chip"></span>
<span class="skeleton-chip"></span>
<span class="skeleton-chip"></span>
<span class="skeleton-chip"></span>
</div>
<div class="skeleton-line price"></div>
</div>
</article>
</template>
<style scoped>
.skeleton-card {
overflow: hidden;
border-radius: 12px;
background: #fff;
border: 1px solid transparent;
}
.skeleton-media,
.skeleton-line,
.skeleton-chip {
position: relative;
overflow: hidden;
background: #eee;
}
.skeleton-media {
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 {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, #f7f7f7, transparent);
animation: shimmer 1.25s infinite;
transform: translateX(-100%);
}
.skeleton-info {
padding: 12px;
}
.skeleton-line {
height: 22px;
border-radius: 6px;
}
.skeleton-line.title {
width: 90%;
margin-bottom: 11px;
}
.skeleton-line.price {
width: 35%;
}
.skeleton-chips {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
.skeleton-chip {
width: 46px;
height: 18px;
border-radius: 5px;
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
</style>
<template>
<article class="skeleton-card">
<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">
<span class="skeleton-chip"></span>
<span class="skeleton-chip"></span>
<span class="skeleton-chip"></span>
<span class="skeleton-chip"></span>
</div>
<div class="skeleton-line price"></div>
</div>
</article>
</template>
<style scoped>
.skeleton-card {
overflow: hidden;
border-radius: 12px;
background: #fff;
border: 1px solid transparent;
}
.skeleton-media,
.skeleton-line,
.skeleton-chip {
position: relative;
overflow: hidden;
background: #eee;
}
.skeleton-media {
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 {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, #f7f7f7, transparent);
animation: shimmer 1.25s infinite;
transform: translateX(-100%);
}
.skeleton-info {
padding: 12px;
}
.skeleton-line {
height: 22px;
border-radius: 6px;
}
.skeleton-line.title {
width: 90%;
margin-bottom: 11px;
}
.skeleton-line.price {
width: 35%;
}
.skeleton-chips {
display: flex;
gap: 6px;
margin-bottom: 12px;
}
.skeleton-chip {
width: 46px;
height: 18px;
border-radius: 5px;
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
</style>
@@ -1,19 +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>
<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,29 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Country } from '~/composables/useProductCenter';
const props = defineProps<{ countries: Country[]; activeCountryId: string | null }>();
const emit = defineEmits<{ 'update:activeCountryId': [id: string | null] }>();
const configuredCountries = computed(() => props.countries);
</script>
<template>
<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: 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>
<script setup lang="ts">
import { computed } from 'vue';
import type { Country } from '~/composables/useProductCenter';
const props = defineProps<{ countries: Country[]; activeCountryId: string | null }>();
const emit = defineEmits<{ 'update:activeCountryId': [id: string | null] }>();
const configuredCountries = computed(() => props.countries);
</script>
<template>
<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: 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,28 +1,28 @@
<script setup lang="ts">
import { shallowRef, watch } from 'vue';
const keyword = defineModel<string>('keyword', { default: '' });
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>
<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 { 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>
<script setup lang="ts">
import { shallowRef, watch } from 'vue';
const keyword = defineModel<string>('keyword', { default: '' });
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>
<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 { 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,24 +1,24 @@
<script setup lang="ts">
import type { Product } from '~/composables/useProductCenter';
defineProps<{ products: Product[]; loading: boolean; showCountry?: boolean }>();
const emit = defineEmits<{ 'clear-filters': []; 'view-all': [] }>();
</script>
<template>
<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(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>
<script setup lang="ts">
import type { Product } from '~/composables/useProductCenter';
defineProps<{ products: Product[]; loading: boolean; showCountry?: boolean }>();
const emit = defineEmits<{ 'clear-filters': []; 'view-all': [] }>();
</script>
<template>
<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(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,25 +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(() => 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">
<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: 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>
<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(() => 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">
<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: 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,45 +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 = reactive<Record<string, boolean>>({});
function selectRootCategory(category: Category): void {
emit('update:activeCategoryId', category.id);
if (category.children.length) expanded[category.id] = !expanded[category.id];
}
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>
<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>
<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>
.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>
<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 = reactive<Record<string, boolean>>({});
function selectRootCategory(category: Category): void {
emit('update:activeCategoryId', category.id);
if (category.children.length) expanded[category.id] = !expanded[category.id];
}
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>
<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>
<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>
.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,75 +1,75 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, shallowRef } from 'vue';
import type { Tag, TagGroup } from '~/composables/useProductCenter';
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));
function selectTag(id: string): void {
emit('toggle-tag', id);
openGroup.value = null;
}
function closeOnOutsideClick(event: MouseEvent): void {
if (!rootElement.value?.contains(event.target as Node)) openGroup.value = null;
}
onMounted(() => document.addEventListener('click', closeOnOutsideClick));
onBeforeUnmount(() => document.removeEventListener('click', closeOnOutsideClick));
</script>
<template>
<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>
</div>
</div>
</template>
<style scoped>
.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>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, shallowRef } from 'vue';
import type { Tag, TagGroup } from '~/composables/useProductCenter';
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));
function selectTag(id: string): void {
emit('toggle-tag', id);
openGroup.value = null;
}
function closeOnOutsideClick(event: MouseEvent): void {
if (!rootElement.value?.contains(event.target as Node)) openGroup.value = null;
}
onMounted(() => document.addEventListener('click', closeOnOutsideClick));
onBeforeUnmount(() => document.removeEventListener('click', closeOnOutsideClick));
</script>
<template>
<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>
</div>
</div>
</template>
<style scoped>
.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>